synodic-client 0.0.1.dev11__tar.gz → 0.0.1.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/pyproject.toml +4 -3
  3. synodic_client-0.0.1.dev13/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/qt.py +7 -51
  5. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/install.py +7 -8
  6. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/screen.py +5 -5
  7. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/tray.py +10 -11
  8. synodic_client-0.0.1.dev13/synodic_client/application/uri.py +24 -0
  9. synodic_client-0.0.1.dev13/synodic_client/protocol.py +75 -0
  10. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/updater.py +3 -3
  11. synodic_client-0.0.1.dev13/tests/unit/qt/__init__.py +1 -0
  12. synodic_client-0.0.1.dev13/tests/unit/qt/conftest.py +10 -0
  13. {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_install_preview.py +8 -6
  14. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_cli.py +4 -0
  15. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_examples.py +1 -1
  16. synodic_client-0.0.1.dev13/tests/unit/test_install_preview.py +491 -0
  17. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_updater.py +1 -1
  18. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_uri.py +1 -1
  19. synodic_client-0.0.1.dev13/tests/unit/windows/__init__.py +1 -0
  20. synodic_client-0.0.1.dev13/tests/unit/windows/conftest.py +9 -0
  21. {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/windows}/test_protocol.py +3 -16
  22. synodic_client-0.0.1.dev11/synodic_client/_version.py +0 -1
  23. synodic_client-0.0.1.dev11/synodic_client/application/threading.py +0 -82
  24. synodic_client-0.0.1.dev11/synodic_client/protocol.py +0 -83
  25. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/LICENSE.md +0 -0
  26. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/README.md +0 -0
  27. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/__init__.py +0 -0
  28. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/__main__.py +0 -0
  29. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/icon.py +0 -0
  31. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/instance.py +0 -0
  32. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/__init__.py +0 -0
  33. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/log_panel.py +0 -0
  34. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/theme.py +0 -0
  35. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/cli.py +0 -0
  36. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/client.py +0 -0
  37. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/config.py +0 -0
  38. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/logging.py +0 -0
  39. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/py.typed +0 -0
  40. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/resolution.py +0 -0
  41. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/conftest.py +0 -0
  43. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_log_panel.py +0 -0
  45. {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_logging.py +0 -0
  46. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_client_updater.py +0 -0
  47. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_client_version.py +0 -0
  48. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_config.py +0 -0
  49. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_install.py +0 -0
  50. {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_resolution.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev11
3
+ Version: 0.0.1.dev13
4
4
  Author-Email: Synodic Software <contact@synodic.software>
5
5
  License: LGPL-3.0-or-later
6
6
  Project-URL: homepage, https://github.com/synodic/synodic-client
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "velopack>=0.0.1369.dev7516",
15
15
  "typer>=0.23.0",
16
16
  ]
17
- version = "0.0.1.dev11"
17
+ version = "0.0.1.dev13"
18
18
 
19
19
  [project.license]
20
20
  text = "LGPL-3.0-or-later"
@@ -86,8 +86,9 @@ quote-style = "single"
86
86
  skip_empty = true
87
87
 
88
88
  [tool.pyrefly]
89
- search_path = [
90
- "synodic_client/...",
89
+ replace-imports-with-any = [
90
+ "velopack",
91
+ "winreg",
91
92
  ]
92
93
 
93
94
  [tool.pdm.version]
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev13'
@@ -3,11 +3,9 @@
3
3
  import ctypes
4
4
  import logging
5
5
  import signal
6
- import subprocess
7
6
  import sys
8
7
  import types
9
8
  from collections.abc import Callable
10
- from urllib.parse import parse_qs, urlparse
11
9
 
12
10
  from porringer.api import API
13
11
  from porringer.schema import LocalConfiguration
@@ -19,6 +17,7 @@ from synodic_client.application.instance import SingleInstance
19
17
  from synodic_client.application.screen.install import InstallPreviewWindow
20
18
  from synodic_client.application.screen.screen import Screen
21
19
  from synodic_client.application.screen.tray import TrayScreen
20
+ from synodic_client.application.uri import parse_uri
22
21
  from synodic_client.client import Client
23
22
  from synodic_client.config import GlobalConfiguration, set_dev_mode
24
23
  from synodic_client.logging import configure_logging
@@ -27,27 +26,6 @@ from synodic_client.resolution import resolve_config, resolve_update_config
27
26
  from synodic_client.updater import initialize_velopack
28
27
 
29
28
 
30
- def parse_uri(uri: str) -> dict[str, str | list[str]]:
31
- """Parse a ``synodic://`` URI into its components.
32
-
33
- Example:
34
- ``synodic://install?manifest=https://example.com/foo.toml``
35
- returns ``{'action': 'install', 'manifest': ['https://example.com/foo.toml']}``.
36
-
37
- Args:
38
- uri: A ``synodic://`` URI string.
39
-
40
- Returns:
41
- A dict with ``'action'`` (the host/path) and any query parameters.
42
- """
43
- parsed = urlparse(uri)
44
- result: dict[str, str | list[str]] = {
45
- 'action': parsed.netloc or parsed.path.strip('/'),
46
- }
47
- result.update(parse_qs(parsed.query))
48
- return result
49
-
50
-
51
29
  def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfiguration]:
52
30
  """Create and configure core services.
53
31
 
@@ -85,28 +63,6 @@ def _process_uri(uri: str, handler: Callable[[str], None]) -> None:
85
63
  handler(manifests[0])
86
64
 
87
65
 
88
- def _suppress_subprocess_consoles() -> None:
89
- """Monkey-patch ``subprocess.Popen`` to hide console windows on Windows.
90
-
91
- When the application is built as a windowed executable (``console=False``
92
- in PyInstaller), every ``subprocess.Popen`` call that launches a console
93
- program (pip, pipx, uv, winget, etc.) would briefly flash a visible
94
- console window. This patch adds ``CREATE_NO_WINDOW`` to *creationflags*
95
- for all calls that don't already set it, suppressing those flashes.
96
- """
97
- if sys.platform != 'win32':
98
- return
99
-
100
- _original_init = subprocess.Popen.__init__
101
-
102
- def _patched_init(self: subprocess.Popen, *args: object, **kwargs: object) -> None: # type: ignore[override]
103
- if 'creationflags' not in kwargs:
104
- kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
105
- _original_init(self, *args, **kwargs) # type: ignore[arg-type]
106
-
107
- subprocess.Popen.__init__ = _patched_init # type: ignore[assignment]
108
-
109
-
110
66
  def _install_exception_hook(logger: logging.Logger) -> None:
111
67
  """Redirect unhandled exceptions to the log file.
112
68
 
@@ -131,7 +87,9 @@ def _init_app() -> QApplication:
131
87
  # Set the App User Model ID so Windows uses our icon on the taskbar
132
88
  # instead of the generic python.exe icon.
133
89
  if sys.platform == 'win32':
134
- ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('synodic.client') # type: ignore[union-attr]
90
+ windll = getattr(ctypes, 'windll', None)
91
+ if windll is not None:
92
+ windll.shell32.SetCurrentProcessExplicitAppUserModelID('synodic.client')
135
93
 
136
94
  app = QApplication([])
137
95
  app.setQuitOnLastWindowClosed(False)
@@ -164,12 +122,10 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
164
122
  # Activate dev-mode namespacing before anything reads config paths.
165
123
  set_dev_mode(dev_mode)
166
124
 
167
- # Suppress console window flashes from subprocess calls (e.g. porringer
168
- # running pip, pipx, uv) before any subprocesses are spawned. Skipped
169
- # in dev mode because the source-run process already has a console.
170
125
  if not dev_mode:
171
- _suppress_subprocess_consoles()
172
- # Initialize Velopack early, before any UI
126
+ # Initialize Velopack early, before any UI.
127
+ # Console window suppression for subprocesses is handled by the
128
+ # PyInstaller runtime hook (rthook_no_console.py).
173
129
  initialize_velopack()
174
130
  register_protocol(sys.executable)
175
131
 
@@ -31,7 +31,7 @@ from porringer.schema import (
31
31
  SetupResults,
32
32
  SubActionProgress,
33
33
  )
34
- from PySide6.QtCore import QObject, Qt, QTimer, Signal
34
+ from PySide6.QtCore import Qt, QThread, QTimer, Signal
35
35
  from PySide6.QtGui import QFont, QKeySequence, QShortcut
36
36
  from PySide6.QtWidgets import (
37
37
  QApplication,
@@ -69,7 +69,6 @@ from synodic_client.application.theme import (
69
69
  MUTED_STYLE,
70
70
  NO_MARGINS,
71
71
  )
72
- from synodic_client.application.threading import ThreadRunner
73
72
 
74
73
  logger = logging.getLogger(__name__)
75
74
 
@@ -83,7 +82,7 @@ def format_cli_command(action: SetupAction) -> str:
83
82
  return action.description
84
83
 
85
84
 
86
- class InstallWorker(QObject):
85
+ class InstallWorker(QThread):
87
86
  """Background worker that executes setup actions via porringer.
88
87
 
89
88
  Uses the ``execute_stream`` async generator to consume progress events
@@ -275,7 +274,7 @@ class SetupPreviewWidget(QWidget):
275
274
  self._preview: SetupResults | None = None
276
275
  self._manifest_path: Path | None = None
277
276
  self._project_directory: Path | None = None
278
- self._runner: ThreadRunner | None = None
277
+ self._runner: QThread | None = None
279
278
  self._cancellation_token: CancellationToken | None = None
280
279
  self._completed_count = 0
281
280
  self._action_statuses: list[str] = []
@@ -586,7 +585,7 @@ class SetupPreviewWidget(QWidget):
586
585
  worker.finished.connect(self._on_install_finished)
587
586
  worker.error.connect(self._on_install_error)
588
587
 
589
- self._runner = ThreadRunner(worker)
588
+ self._runner = worker
590
589
  self._runner.start()
591
590
 
592
591
  def _on_action_started(self, action: SetupAction) -> None:
@@ -686,7 +685,7 @@ class InstallPreviewWindow(QMainWindow):
686
685
  self._porringer = porringer
687
686
  self._manifest_url = manifest_url
688
687
  self._temp_dir_path: str | None = None
689
- self._runner: ThreadRunner | None = None
688
+ self._runner: QThread | None = None
690
689
 
691
690
  # Default project directory to the current working directory
692
691
  self._project_directory: Path = Path.cwd()
@@ -788,7 +787,7 @@ class InstallPreviewWindow(QMainWindow):
788
787
  preview_worker.finished.connect(self._preview_widget.on_preview_finished)
789
788
  preview_worker.error.connect(self._preview_widget.on_preview_error)
790
789
 
791
- self._runner = ThreadRunner(preview_worker)
790
+ self._runner = preview_worker
792
791
  self._runner.start()
793
792
 
794
793
  # --- Preview callback (intercepts to capture temp dir) ---
@@ -804,7 +803,7 @@ class InstallPreviewWindow(QMainWindow):
804
803
  self._preview_widget.on_preview_ready(preview, manifest_path, temp_dir_path)
805
804
 
806
805
 
807
- class PreviewWorker(QObject):
806
+ class PreviewWorker(QThread):
808
807
  """Background worker that downloads a manifest and performs a dry-run.
809
808
 
810
809
  Combines two stages into a single background pipeline:
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
10
10
 
11
11
  from porringer.api import API
12
12
  from porringer.schema import PluginInfo, PluginKind
13
- from PySide6.QtCore import Qt, Signal
13
+ from PySide6.QtCore import Qt, QThread, Signal
14
14
  from PySide6.QtGui import QStandardItem
15
15
  from PySide6.QtWidgets import (
16
16
  QComboBox,
@@ -44,7 +44,6 @@ from synodic_client.application.theme import (
44
44
  PLUGIN_TOGGLE_STYLE,
45
45
  PLUGIN_UPDATE_STYLE,
46
46
  )
47
- from synodic_client.application.threading import ThreadRunner
48
47
  from synodic_client.config import GlobalConfiguration, save_config
49
48
 
50
49
  if TYPE_CHECKING:
@@ -490,7 +489,7 @@ class ProjectsView(QWidget):
490
489
  """
491
490
  super().__init__(parent)
492
491
  self._porringer = porringer
493
- self._runner: ThreadRunner | None = None
492
+ self._runner: QThread | None = None
494
493
  self._init_ui()
495
494
 
496
495
  def _init_ui(self) -> None:
@@ -548,7 +547,8 @@ class ProjectsView(QWidget):
548
547
 
549
548
  if not exists:
550
549
  # Grey out entries whose directory no longer exists on disk
551
- item = self._combo.model().item(idx) # type: ignore[union-attr]
550
+ model = self._combo.model()
551
+ item = model.item(idx) if hasattr(model, 'item') else None
552
552
  if isinstance(item, QStandardItem):
553
553
  item.setForeground(self.palette().placeholderText())
554
554
  item.setToolTip(f'{tooltip} \u2014 directory not found' if tooltip else 'Directory not found')
@@ -645,7 +645,7 @@ class ProjectsView(QWidget):
645
645
  preview_worker.finished.connect(self._preview.on_preview_finished)
646
646
  preview_worker.error.connect(self._on_preview_error)
647
647
 
648
- self._runner = ThreadRunner(preview_worker)
648
+ self._runner = preview_worker
649
649
  self._runner.start()
650
650
 
651
651
  def _on_preview_error(self, message: str) -> None:
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from porringer.api import API
8
8
  from porringer.schema import SetupParameters, SyncStrategy
9
- from PySide6.QtCore import QObject, QTimer, Signal
9
+ from PySide6.QtCore import QThread, QTimer, Signal
10
10
  from PySide6.QtGui import QAction
11
11
  from PySide6.QtWidgets import (
12
12
  QApplication,
@@ -27,7 +27,6 @@ from PySide6.QtWidgets import (
27
27
  from synodic_client.application.icon import app_icon
28
28
  from synodic_client.application.screen.screen import MainWindow
29
29
  from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH
30
- from synodic_client.application.threading import ThreadRunner
31
30
  from synodic_client.client import Client
32
31
  from synodic_client.config import GlobalConfiguration
33
32
  from synodic_client.logging import open_log
@@ -37,7 +36,7 @@ from synodic_client.updater import GITHUB_REPO_URL, UpdateChannel, UpdateInfo
37
36
  logger = logging.getLogger(__name__)
38
37
 
39
38
 
40
- class UpdateCheckWorker(QObject):
39
+ class UpdateCheckWorker(QThread):
41
40
  """Worker for checking updates in a background thread."""
42
41
 
43
42
  finished = Signal(object) # UpdateInfo
@@ -58,7 +57,7 @@ class UpdateCheckWorker(QObject):
58
57
  self.error.emit(str(e))
59
58
 
60
59
 
61
- class UpdateDownloadWorker(QObject):
60
+ class UpdateDownloadWorker(QThread):
62
61
  """Worker for downloading updates in a background thread."""
63
62
 
64
63
  finished = Signal(bool) # success status
@@ -84,7 +83,7 @@ class UpdateDownloadWorker(QObject):
84
83
  self.error.emit(str(e))
85
84
 
86
85
 
87
- class ToolUpdateWorker(QObject):
86
+ class ToolUpdateWorker(QThread):
88
87
  """Worker for re-syncing manifest-declared tools in a background thread."""
89
88
 
90
89
  finished = Signal(int) # number of manifests processed
@@ -209,8 +208,8 @@ class TrayScreen:
209
208
  self._client = client
210
209
  self._window = window
211
210
  self._config = config
212
- self._runner: ThreadRunner | None = None
213
- self._tool_runner: ThreadRunner | None = None
211
+ self._runner: QThread | None = None
212
+ self._tool_runner: QThread | None = None
214
213
  self._progress_dialog: QProgressDialog | None = None
215
214
  self._pending_update_info: UpdateInfo | None = None
216
215
  self._download_cancelled = False
@@ -429,7 +428,7 @@ class TrayScreen:
429
428
  worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent))
430
429
  worker.error.connect(lambda error: self._on_update_check_error(error, silent=silent))
431
430
 
432
- self._runner = ThreadRunner(worker)
431
+ self._runner = worker
433
432
  self._runner.start()
434
433
 
435
434
  def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
@@ -509,7 +508,7 @@ class TrayScreen:
509
508
  worker.finished.connect(self._on_tool_update_finished)
510
509
  worker.error.connect(self._on_tool_update_error)
511
510
 
512
- self._tool_runner = ThreadRunner(worker)
511
+ self._tool_runner = worker
513
512
  self._tool_runner.start()
514
513
 
515
514
  def _on_single_plugin_update(self, plugin_name: str) -> None:
@@ -525,7 +524,7 @@ class TrayScreen:
525
524
  worker.finished.connect(self._on_tool_update_finished)
526
525
  worker.error.connect(self._on_tool_update_error)
527
526
 
528
- self._tool_runner = ThreadRunner(worker)
527
+ self._tool_runner = worker
529
528
  self._tool_runner.start()
530
529
 
531
530
  def _on_tool_update_finished(self, count: int) -> None:
@@ -570,7 +569,7 @@ class TrayScreen:
570
569
  worker.progress.connect(self._on_download_progress)
571
570
  worker.error.connect(self._on_download_error)
572
571
 
573
- self._runner = ThreadRunner(worker)
572
+ self._runner = worker
574
573
  self._runner.start()
575
574
 
576
575
  def _on_download_cancelled(self) -> None:
@@ -0,0 +1,24 @@
1
+ """URI parsing utilities for the ``synodic://`` scheme."""
2
+
3
+ from urllib.parse import parse_qs, urlparse
4
+
5
+
6
+ def parse_uri(uri: str) -> dict[str, str | list[str]]:
7
+ """Parse a ``synodic://`` URI into its components.
8
+
9
+ Example:
10
+ ``synodic://install?manifest=https://example.com/foo.toml``
11
+ returns ``{'action': 'install', 'manifest': ['https://example.com/foo.toml']}``.
12
+
13
+ Args:
14
+ uri: A ``synodic://`` URI string.
15
+
16
+ Returns:
17
+ A dict with ``'action'`` (the host/path) and any query parameters.
18
+ """
19
+ parsed = urlparse(uri)
20
+ result: dict[str, str | list[str]] = {
21
+ 'action': parsed.netloc or parsed.path.strip('/'),
22
+ }
23
+ result.update(parse_qs(parsed.query))
24
+ return result
@@ -0,0 +1,75 @@
1
+ r"""URI protocol handler registration for the ``synodic://`` scheme.
2
+
3
+ On Windows this writes registry keys under ``HKCU\Software\Classes\synodic``
4
+ so that clicking a ``synodic://`` link in a browser or file manager launches the
5
+ Synodic Client with the URI as an argument.
6
+
7
+ Other platforms are stubbed with no-op implementations.
8
+ """
9
+
10
+ import logging
11
+ import sys
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ PROTOCOL_NAME = 'synodic'
16
+ _PROTOCOL_DESCRIPTION = 'Synodic Client Protocol'
17
+
18
+
19
+ if sys.platform == 'win32':
20
+ import ctypes
21
+ import winreg
22
+
23
+ # Bind RegDeleteTreeW for recursive registry key deletion in a single call.
24
+ _reg_delete_tree = ctypes.windll.advapi32.RegDeleteTreeW
25
+ _reg_delete_tree.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p]
26
+ _reg_delete_tree.restype = ctypes.c_long
27
+
28
+ _ERROR_FILE_NOT_FOUND = 2
29
+
30
+ def register_protocol(exe_path: str) -> None:
31
+ """Register the ``synodic://`` URI protocol handler.
32
+
33
+ Args:
34
+ exe_path: Absolute path to the application executable.
35
+ """
36
+ key_path = f'Software\\Classes\\{PROTOCOL_NAME}'
37
+
38
+ try:
39
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key:
40
+ winreg.SetValueEx(key, '', 0, winreg.REG_SZ, _PROTOCOL_DESCRIPTION)
41
+ winreg.SetValueEx(key, 'URL Protocol', 0, winreg.REG_SZ, '')
42
+
43
+ command_path = f'{key_path}\\shell\\open\\command'
44
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, command_path) as key:
45
+ winreg.SetValueEx(key, '', 0, winreg.REG_SZ, f'"{exe_path}" "%1"')
46
+
47
+ logger.info('Registered synodic:// protocol handler -> %s', exe_path)
48
+ except OSError:
49
+ logger.exception('Failed to register synodic:// protocol handler')
50
+
51
+ def remove_protocol() -> None:
52
+ """Remove the ``synodic://`` URI protocol handler registration."""
53
+ key_path = f'Software\\Classes\\{PROTOCOL_NAME}'
54
+
55
+ result = _reg_delete_tree(winreg.HKEY_CURRENT_USER, key_path)
56
+ if result == 0:
57
+ logger.info('Removed synodic:// protocol handler registration')
58
+ elif result == _ERROR_FILE_NOT_FOUND:
59
+ logger.debug('Protocol handler registration not found, nothing to remove')
60
+ else:
61
+ logger.error('Failed to remove synodic:// protocol handler (error code %d)', result)
62
+
63
+ else:
64
+
65
+ def register_protocol(exe_path: str) -> None:
66
+ """Register the ``synodic://`` URI protocol handler (no-op on non-Windows).
67
+
68
+ Args:
69
+ exe_path: Absolute path to the application executable.
70
+ """
71
+ logger.warning('Protocol registration is only supported on Windows (current: %s)', sys.platform)
72
+
73
+ def remove_protocol() -> None:
74
+ """Remove the ``synodic://`` URI protocol handler registration (no-op on non-Windows)."""
75
+ logger.warning('Protocol removal is only supported on Windows (current: %s)', sys.platform)
@@ -294,11 +294,11 @@ class Updater:
294
294
  return self._velopack_manager
295
295
 
296
296
  try:
297
- options = velopack.UpdateOptions() # type: ignore[attr-defined]
297
+ options = velopack.UpdateOptions()
298
298
  options.allow_version_downgrade = False
299
299
  options.explicit_channel = self._config.channel_name
300
300
 
301
- self._velopack_manager = velopack.UpdateManager( # type: ignore[attr-defined]
301
+ self._velopack_manager = velopack.UpdateManager(
302
302
  self._config.repo_url,
303
303
  options,
304
304
  )
@@ -340,7 +340,7 @@ def initialize_velopack() -> None:
340
340
  On Windows, install/uninstall hooks register the ``synodic://`` URI protocol.
341
341
  """
342
342
  try:
343
- app = velopack.App() # type: ignore[attr-defined]
343
+ app = velopack.App()
344
344
  app.on_after_install_fast_callback(_on_after_install)
345
345
  app.on_before_uninstall_fast_callback(_on_before_uninstall)
346
346
  app.run()
@@ -0,0 +1 @@
1
+ """Qt-dependent tests."""
@@ -0,0 +1,10 @@
1
+ """Configuration for Qt-dependent tests.
2
+
3
+ Tests in this directory require PySide6. When the Qt runtime libraries
4
+ are not available (e.g. on headless Linux CI), the entire directory is
5
+ skipped automatically.
6
+ """
7
+
8
+ import pytest
9
+
10
+ pytest.importorskip("PySide6.QtWidgets", reason="PySide6 requires system Qt libraries")
@@ -17,7 +17,6 @@ from porringer.schema import (
17
17
  SkipReason,
18
18
  )
19
19
 
20
- from synodic_client.application.qt import parse_uri
21
20
  from synodic_client.application.screen import (
22
21
  ACTION_KIND_LABELS,
23
22
  SKIP_REASON_LABELS,
@@ -29,6 +28,7 @@ from synodic_client.application.screen.install import (
29
28
  format_cli_command,
30
29
  resolve_local_path,
31
30
  )
31
+ from synodic_client.application.uri import parse_uri
32
32
 
33
33
 
34
34
  class TestParseUriInstall:
@@ -231,11 +231,12 @@ class TestResolveLocalPath:
231
231
  assert resolve_local_path('https://example.com/porringer.json') is None
232
232
 
233
233
  @staticmethod
234
- def test_absolute_path_returns_path() -> None:
234
+ def test_absolute_path_returns_path(tmp_path: Path) -> None:
235
235
  """Absolute OS paths should resolve."""
236
- result = resolve_local_path('C:\\Users\\test\\porringer.json')
236
+ path = str(tmp_path / 'porringer.json')
237
+ result = resolve_local_path(path)
237
238
  assert result is not None
238
- assert result == Path('C:\\Users\\test\\porringer.json')
239
+ assert result == Path(path)
239
240
 
240
241
  @staticmethod
241
242
  def test_file_uri_returns_path() -> None:
@@ -283,10 +284,11 @@ class TestPreviewWorkerLocal:
283
284
  porringer.sync.download.assert_not_called()
284
285
 
285
286
  @staticmethod
286
- def test_local_manifest_not_found() -> None:
287
+ def test_local_manifest_not_found(tmp_path: Path) -> None:
287
288
  """Verify PreviewWorker emits error for missing local file."""
289
+ path = str(tmp_path / 'nonexistent' / 'porringer.json')
288
290
  porringer = MagicMock()
289
- worker = PreviewWorker(porringer, 'C:\\nonexistent\\porringer.json')
291
+ worker = PreviewWorker(porringer, path)
290
292
 
291
293
  errors: list[str] = []
292
294
  worker.error.connect(errors.append)
@@ -2,6 +2,10 @@
2
2
 
3
3
  from unittest.mock import patch
4
4
 
5
+ import pytest
6
+
7
+ pytest.importorskip('PySide6.QtWidgets', reason='PySide6 requires system Qt libraries')
8
+
5
9
  from typer.testing import CliRunner
6
10
 
7
11
  from synodic_client.cli import app
@@ -6,7 +6,7 @@ These validate client-level invariants — not porringer manifest semantics.
6
6
  import re
7
7
  from pathlib import Path
8
8
 
9
- from synodic_client.application.qt import parse_uri
9
+ from synodic_client.application.uri import parse_uri
10
10
 
11
11
  _URI_PATTERN = re.compile(r'synodic://\S+')
12
12