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.
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/pyproject.toml +4 -3
- synodic_client-0.0.1.dev13/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/qt.py +7 -51
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/install.py +7 -8
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/screen.py +5 -5
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/tray.py +10 -11
- synodic_client-0.0.1.dev13/synodic_client/application/uri.py +24 -0
- synodic_client-0.0.1.dev13/synodic_client/protocol.py +75 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/updater.py +3 -3
- synodic_client-0.0.1.dev13/tests/unit/qt/__init__.py +1 -0
- synodic_client-0.0.1.dev13/tests/unit/qt/conftest.py +10 -0
- {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_install_preview.py +8 -6
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_cli.py +4 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_examples.py +1 -1
- synodic_client-0.0.1.dev13/tests/unit/test_install_preview.py +491 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_updater.py +1 -1
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_uri.py +1 -1
- synodic_client-0.0.1.dev13/tests/unit/windows/__init__.py +1 -0
- synodic_client-0.0.1.dev13/tests/unit/windows/conftest.py +9 -0
- {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/windows}/test_protocol.py +3 -16
- synodic_client-0.0.1.dev11/synodic_client/_version.py +0 -1
- synodic_client-0.0.1.dev11/synodic_client/application/threading.py +0 -82
- synodic_client-0.0.1.dev11/synodic_client/protocol.py +0 -83
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/README.md +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/theme.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev11/tests/unit → synodic_client-0.0.1.dev13/tests/unit/qt}/test_logging.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/tests/unit/test_resolution.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
90
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
#
|
|
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
|
|
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(
|
|
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:
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
648
|
+
self._runner = preview_worker
|
|
649
649
|
self._runner.start()
|
|
650
650
|
|
|
651
651
|
def _on_preview_error(self, message: str) -> None:
|
{synodic_client-0.0.1.dev11 → synodic_client-0.0.1.dev13}/synodic_client/application/screen/tray.py
RENAMED
|
@@ -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
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
213
|
-
self._tool_runner:
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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()
|
|
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(
|
|
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()
|
|
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
|
-
|
|
236
|
+
path = str(tmp_path / 'porringer.json')
|
|
237
|
+
result = resolve_local_path(path)
|
|
237
238
|
assert result is not None
|
|
238
|
-
assert result == Path(
|
|
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,
|
|
291
|
+
worker = PreviewWorker(porringer, path)
|
|
290
292
|
|
|
291
293
|
errors: list[str] = []
|
|
292
294
|
worker.error.connect(errors.append)
|
|
@@ -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.
|
|
9
|
+
from synodic_client.application.uri import parse_uri
|
|
10
10
|
|
|
11
11
|
_URI_PATTERN = re.compile(r'synodic://\S+')
|
|
12
12
|
|