synodic-client 0.0.1.dev62__tar.gz → 0.0.1.dev64__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.dev62 → synodic_client-0.0.1.dev64}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/pyproject.toml +1 -1
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/bootstrap.py +2 -0
- synodic_client-0.0.1.dev64/synodic_client/application/config_store.py +65 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/qt.py +36 -3
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/schema.py +21 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/projects.py +16 -11
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/screen.py +36 -29
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/settings.py +15 -25
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/spinner.py +46 -36
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tool_update_controller.py +119 -85
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tray.py +19 -27
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/update_banner.py +25 -17
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/theme.py +1 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/update_controller.py +38 -27
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/workers.py +20 -7
- synodic_client-0.0.1.dev64/synodic_client/subprocess_patch.py +82 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_gather_packages.py +38 -32
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_settings.py +39 -70
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_tray_window_show.py +25 -7
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_controller.py +30 -35
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/README.md +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/init.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install_workers.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/plugin_row.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/schema.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/schema.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/startup.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_init.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_updater.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/test_protocol.py +0 -0
- {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/test_startup.py +0 -0
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -18,12 +18,14 @@ import sys
|
|
|
18
18
|
from synodic_client.config import set_dev_mode
|
|
19
19
|
from synodic_client.logging import configure_logging
|
|
20
20
|
from synodic_client.protocol import extract_uri_from_args
|
|
21
|
+
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
|
|
21
22
|
from synodic_client.updater import initialize_velopack
|
|
22
23
|
|
|
23
24
|
# Parse flags early so logging uses the right filename and level.
|
|
24
25
|
_dev_mode = '--dev' in sys.argv[1:]
|
|
25
26
|
_debug = '--debug' in sys.argv[1:]
|
|
26
27
|
set_dev_mode(_dev_mode)
|
|
28
|
+
_apply_subprocess_patch()
|
|
27
29
|
|
|
28
30
|
configure_logging(debug=_debug)
|
|
29
31
|
initialize_velopack()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Centralized configuration store.
|
|
2
|
+
|
|
3
|
+
Provides a single source of truth for :class:`ResolvedConfig` so that
|
|
4
|
+
every consumer (ToolsView, SettingsWindow, UpdateController,
|
|
5
|
+
ToolUpdateOrchestrator) always reads the same snapshot and receives
|
|
6
|
+
change notifications through a Qt signal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, Signal
|
|
12
|
+
|
|
13
|
+
from synodic_client.resolution import ResolvedConfig, resolve_config, update_user_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigStore(QObject):
|
|
17
|
+
"""Observable wrapper around :class:`ResolvedConfig`.
|
|
18
|
+
|
|
19
|
+
All config mutations go through :meth:`update` (which persists to
|
|
20
|
+
disk) or :meth:`set` (which replaces without persisting). Both
|
|
21
|
+
emit :attr:`changed` so every connected consumer stays in sync.
|
|
22
|
+
|
|
23
|
+
Typical usage::
|
|
24
|
+
|
|
25
|
+
store = ConfigStore(initial_config)
|
|
26
|
+
store.changed.connect(some_consumer.on_config_changed)
|
|
27
|
+
store.update(auto_apply=False) # persists + emits
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
changed = Signal(object)
|
|
31
|
+
"""Emitted with the new ``ResolvedConfig`` after every mutation."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, config: ResolvedConfig | None = None, parent: QObject | None = None) -> None:
|
|
34
|
+
"""Create a new store, optionally seeded with *config*."""
|
|
35
|
+
super().__init__(parent)
|
|
36
|
+
self._config = config if config is not None else resolve_config()
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def config(self) -> ResolvedConfig:
|
|
40
|
+
"""The current configuration snapshot."""
|
|
41
|
+
return self._config
|
|
42
|
+
|
|
43
|
+
def update(self, **changes: object) -> ResolvedConfig:
|
|
44
|
+
"""Persist *changes* to disk and broadcast the new config.
|
|
45
|
+
|
|
46
|
+
Wraps :func:`~synodic_client.resolution.update_user_config`.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
**changes: Field-name / value pairs forwarded to
|
|
50
|
+
:func:`update_user_config`.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The fresh :class:`ResolvedConfig`.
|
|
54
|
+
"""
|
|
55
|
+
self._config = update_user_config(**changes)
|
|
56
|
+
self.changed.emit(self._config)
|
|
57
|
+
return self._config
|
|
58
|
+
|
|
59
|
+
def set(self, config: ResolvedConfig) -> None:
|
|
60
|
+
"""Replace the config without persisting and notify listeners.
|
|
61
|
+
|
|
62
|
+
Use for externally resolved configs (e.g. passed at startup).
|
|
63
|
+
"""
|
|
64
|
+
self._config = config
|
|
65
|
+
self.changed.emit(self._config)
|
|
@@ -15,6 +15,7 @@ from porringer.schema import LocalConfiguration
|
|
|
15
15
|
from PySide6.QtCore import QEvent, QObject, Qt, QTimer
|
|
16
16
|
from PySide6.QtWidgets import QApplication, QWidget
|
|
17
17
|
|
|
18
|
+
from synodic_client.application.config_store import ConfigStore
|
|
18
19
|
from synodic_client.application.icon import app_icon
|
|
19
20
|
from synodic_client.application.init import run_startup_preamble
|
|
20
21
|
from synodic_client.application.instance import SingleInstance
|
|
@@ -31,6 +32,7 @@ from synodic_client.resolution import (
|
|
|
31
32
|
resolve_config,
|
|
32
33
|
resolve_update_config,
|
|
33
34
|
)
|
|
35
|
+
from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
|
|
34
36
|
from synodic_client.updater import initialize_velopack
|
|
35
37
|
|
|
36
38
|
|
|
@@ -72,6 +74,23 @@ def _process_uri(uri: str, handler: Callable[[str], None]) -> None:
|
|
|
72
74
|
handler(manifests[0])
|
|
73
75
|
|
|
74
76
|
|
|
77
|
+
def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None:
|
|
78
|
+
"""Cancel every pending asyncio task on *loop*.
|
|
79
|
+
|
|
80
|
+
Called synchronously from the ``aboutToQuit`` handler. Each task
|
|
81
|
+
receives a cancellation request; when the event loop processes its
|
|
82
|
+
remaining iterations the ``CancelledError`` propagates and the
|
|
83
|
+
tasks finish cleanly.
|
|
84
|
+
"""
|
|
85
|
+
_logger = logging.getLogger(__name__)
|
|
86
|
+
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
|
|
87
|
+
if not pending:
|
|
88
|
+
return
|
|
89
|
+
_logger.info('Cancelling %d pending async task(s)', len(pending))
|
|
90
|
+
for task in pending:
|
|
91
|
+
task.cancel()
|
|
92
|
+
|
|
93
|
+
|
|
75
94
|
def _install_exception_hook(logger: logging.Logger) -> None:
|
|
76
95
|
"""Redirect unhandled exceptions to the log file.
|
|
77
96
|
|
|
@@ -163,6 +182,7 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
|
|
|
163
182
|
"""
|
|
164
183
|
# Activate dev-mode namespacing before anything reads config paths.
|
|
165
184
|
set_dev_mode(dev_mode)
|
|
185
|
+
_apply_subprocess_patch()
|
|
166
186
|
|
|
167
187
|
# Configure logging before Velopack so install/uninstall hooks and
|
|
168
188
|
# first-run diagnostics are captured in the log file.
|
|
@@ -197,8 +217,9 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
|
|
|
197
217
|
sys.exit(0)
|
|
198
218
|
instance.start_server()
|
|
199
219
|
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
_store = ConfigStore(config)
|
|
221
|
+
_screen = Screen(porringer, _store)
|
|
222
|
+
_tray = TrayScreen(app, client, _screen.window, store=_store)
|
|
202
223
|
|
|
203
224
|
# Keep install preview windows alive until the app exits
|
|
204
225
|
_install_windows: list[InstallPreviewWindow] = []
|
|
@@ -208,7 +229,7 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
|
|
|
208
229
|
window = InstallPreviewWindow(
|
|
209
230
|
porringer,
|
|
210
231
|
manifest_url,
|
|
211
|
-
config=config,
|
|
232
|
+
config=_store.config,
|
|
212
233
|
)
|
|
213
234
|
_install_windows.append(window)
|
|
214
235
|
window.show()
|
|
@@ -221,6 +242,18 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
|
|
|
221
242
|
if uri:
|
|
222
243
|
_process_uri(uri, _handle_install_uri)
|
|
223
244
|
|
|
245
|
+
# --- Graceful shutdown ---
|
|
246
|
+
# aboutToQuit fires synchronously when app.quit() is called but
|
|
247
|
+
# before the event loop stops, giving us a window to cancel
|
|
248
|
+
# in-flight async tasks and stop timers.
|
|
249
|
+
|
|
250
|
+
def _on_about_to_quit() -> None:
|
|
251
|
+
logger.info('Application shutting down — cancelling async tasks')
|
|
252
|
+
_tray.shutdown()
|
|
253
|
+
_cancel_all_tasks(loop)
|
|
254
|
+
|
|
255
|
+
app.aboutToQuit.connect(_on_about_to_quit)
|
|
256
|
+
|
|
224
257
|
# qasync integrates the asyncio event loop with Qt's event loop,
|
|
225
258
|
# enabling async/await usage in the GUI layer without dedicated threads.
|
|
226
259
|
with loop:
|
{synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/schema.py
RENAMED
|
@@ -51,3 +51,24 @@ class ToolUpdateResult:
|
|
|
51
51
|
failed: int = 0
|
|
52
52
|
updated_packages: set[str] = field(default_factory=set)
|
|
53
53
|
"""Package names that were successfully upgraded."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class UpdateTarget:
|
|
58
|
+
"""Identifies the scope of a manual tool update.
|
|
59
|
+
|
|
60
|
+
Passed to the shared completion handler so it can clear the correct
|
|
61
|
+
updating state and derive timestamp keys. ``None`` (the default in
|
|
62
|
+
the handler) means the update was periodic / automatic.
|
|
63
|
+
|
|
64
|
+
When *package* is empty the update targeted an entire plugin;
|
|
65
|
+
otherwise it targeted one specific package within the plugin.
|
|
66
|
+
*plugin* always carries the signal key (possibly composite
|
|
67
|
+
``"plugin:tag"``).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
plugin: str
|
|
71
|
+
"""Signal key for the plugin (may be composite ``"name:tag"``)."""
|
|
72
|
+
|
|
73
|
+
package: str = ''
|
|
74
|
+
"""Package name, or empty when the whole plugin was updated."""
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
from porringer.api import API
|
|
10
11
|
from porringer.backend.command.core.discovery import DiscoveredPlugins
|
|
@@ -22,9 +23,11 @@ from synodic_client.application.data import DataCoordinator
|
|
|
22
23
|
from synodic_client.application.screen.install import SetupPreviewWidget
|
|
23
24
|
from synodic_client.application.screen.schema import PreviewPhase
|
|
24
25
|
from synodic_client.application.screen.sidebar import ManifestSidebar
|
|
25
|
-
from synodic_client.application.screen.spinner import
|
|
26
|
+
from synodic_client.application.screen.spinner import LoadingIndicator
|
|
26
27
|
from synodic_client.application.theme import COMPACT_MARGINS
|
|
27
|
-
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from synodic_client.application.config_store import ConfigStore
|
|
28
31
|
|
|
29
32
|
logger = logging.getLogger(__name__)
|
|
30
33
|
|
|
@@ -41,7 +44,7 @@ class ProjectsView(QWidget):
|
|
|
41
44
|
def __init__(
|
|
42
45
|
self,
|
|
43
46
|
porringer: API,
|
|
44
|
-
|
|
47
|
+
store: ConfigStore,
|
|
45
48
|
parent: QWidget | None = None,
|
|
46
49
|
*,
|
|
47
50
|
coordinator: DataCoordinator | None = None,
|
|
@@ -50,14 +53,14 @@ class ProjectsView(QWidget):
|
|
|
50
53
|
|
|
51
54
|
Args:
|
|
52
55
|
porringer: The porringer API instance.
|
|
53
|
-
|
|
56
|
+
store: The centralised :class:`ConfigStore`.
|
|
54
57
|
parent: Optional parent widget.
|
|
55
58
|
coordinator: Shared data coordinator for validated directory
|
|
56
59
|
data.
|
|
57
60
|
"""
|
|
58
61
|
super().__init__(parent)
|
|
59
62
|
self._porringer = porringer
|
|
60
|
-
self.
|
|
63
|
+
self._store = store
|
|
61
64
|
self._coordinator = coordinator
|
|
62
65
|
self._refresh_in_progress = False
|
|
63
66
|
self._pending_select: Path | None = None
|
|
@@ -91,9 +94,10 @@ class ProjectsView(QWidget):
|
|
|
91
94
|
self._empty_placeholder.setStyleSheet('color: grey; font-size: 13px;')
|
|
92
95
|
self._stack.addWidget(self._empty_placeholder)
|
|
93
96
|
|
|
94
|
-
|
|
97
|
+
self._loading_indicator = LoadingIndicator('Loading projects\u2026')
|
|
98
|
+
self._stack.addWidget(self._loading_indicator)
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
outer.addLayout(right, stretch=1)
|
|
97
101
|
|
|
98
102
|
# --- Public API ---
|
|
99
103
|
|
|
@@ -106,7 +110,8 @@ class ProjectsView(QWidget):
|
|
|
106
110
|
async def _async_refresh(self) -> None:
|
|
107
111
|
"""Refresh the sidebar and stacked widgets from the porringer cache."""
|
|
108
112
|
self._refresh_in_progress = True
|
|
109
|
-
self.
|
|
113
|
+
self._loading_indicator.start()
|
|
114
|
+
self._stack.setCurrentWidget(self._loading_indicator)
|
|
110
115
|
self._sidebar.set_enabled(False)
|
|
111
116
|
|
|
112
117
|
try:
|
|
@@ -161,13 +166,13 @@ class ProjectsView(QWidget):
|
|
|
161
166
|
widget.load(
|
|
162
167
|
str(path),
|
|
163
168
|
project_directory=path if path.is_dir() else path.parent,
|
|
164
|
-
detect_updates=self.
|
|
169
|
+
detect_updates=self._store.config.detect_updates,
|
|
165
170
|
)
|
|
166
171
|
|
|
167
172
|
except Exception:
|
|
168
173
|
logger.exception('Failed to refresh projects')
|
|
169
174
|
finally:
|
|
170
|
-
self.
|
|
175
|
+
self._loading_indicator.stop()
|
|
171
176
|
self._sidebar.set_enabled(True)
|
|
172
177
|
self._refresh_in_progress = False
|
|
173
178
|
|
|
@@ -194,7 +199,7 @@ class ProjectsView(QWidget):
|
|
|
194
199
|
self._porringer,
|
|
195
200
|
self,
|
|
196
201
|
show_close=False,
|
|
197
|
-
config=self.
|
|
202
|
+
config=self._store.config,
|
|
198
203
|
)
|
|
199
204
|
widget._discovered_plugins = discovered
|
|
200
205
|
widget.install_finished.connect(self._on_install_finished)
|
|
@@ -34,6 +34,7 @@ from PySide6.QtWidgets import (
|
|
|
34
34
|
QWidget,
|
|
35
35
|
)
|
|
36
36
|
|
|
37
|
+
from synodic_client.application.config_store import ConfigStore
|
|
37
38
|
from synodic_client.application.data import DataCoordinator
|
|
38
39
|
from synodic_client.application.icon import app_icon
|
|
39
40
|
from synodic_client.application.screen.plugin_row import (
|
|
@@ -50,7 +51,7 @@ from synodic_client.application.screen.schema import (
|
|
|
50
51
|
ProjectInstance,
|
|
51
52
|
RefreshData,
|
|
52
53
|
)
|
|
53
|
-
from synodic_client.application.screen.spinner import
|
|
54
|
+
from synodic_client.application.screen.spinner import LoadingIndicator
|
|
54
55
|
from synodic_client.application.screen.update_banner import UpdateBanner
|
|
55
56
|
from synodic_client.application.theme import (
|
|
56
57
|
COMPACT_MARGINS,
|
|
@@ -65,7 +66,6 @@ from synodic_client.application.theme import (
|
|
|
65
66
|
SEARCH_INPUT_STYLE,
|
|
66
67
|
SETTINGS_GEAR_STYLE,
|
|
67
68
|
)
|
|
68
|
-
from synodic_client.resolution import ResolvedConfig, update_user_config
|
|
69
69
|
|
|
70
70
|
logger = logging.getLogger(__name__)
|
|
71
71
|
|
|
@@ -111,7 +111,7 @@ class ToolsView(QWidget):
|
|
|
111
111
|
def __init__(
|
|
112
112
|
self,
|
|
113
113
|
porringer: API,
|
|
114
|
-
|
|
114
|
+
store: ConfigStore,
|
|
115
115
|
parent: QWidget | None = None,
|
|
116
116
|
*,
|
|
117
117
|
coordinator: DataCoordinator | None = None,
|
|
@@ -120,7 +120,7 @@ class ToolsView(QWidget):
|
|
|
120
120
|
|
|
121
121
|
Args:
|
|
122
122
|
porringer: The porringer API instance.
|
|
123
|
-
|
|
123
|
+
store: The centralised :class:`ConfigStore`.
|
|
124
124
|
parent: Optional parent widget.
|
|
125
125
|
coordinator: Shared data coordinator. When provided, the
|
|
126
126
|
view delegates plugin/directory fetching to the
|
|
@@ -128,7 +128,7 @@ class ToolsView(QWidget):
|
|
|
128
128
|
"""
|
|
129
129
|
super().__init__(parent)
|
|
130
130
|
self._porringer = porringer
|
|
131
|
-
self.
|
|
131
|
+
self._store = store
|
|
132
132
|
self._coordinator = coordinator
|
|
133
133
|
self._section_widgets: list[QWidget] = []
|
|
134
134
|
self._filter_chips: dict[str, FilterChip] = {}
|
|
@@ -197,7 +197,8 @@ class ToolsView(QWidget):
|
|
|
197
197
|
self._scroll.setWidget(self._container)
|
|
198
198
|
outer.addWidget(self._scroll)
|
|
199
199
|
|
|
200
|
-
self.
|
|
200
|
+
self._loading_indicator = LoadingIndicator('Loading tools\u2026')
|
|
201
|
+
outer.addWidget(self._loading_indicator)
|
|
201
202
|
|
|
202
203
|
# Periodic timer to refresh relative timestamps (every 60s)
|
|
203
204
|
self._timestamp_timer = QTimer(self)
|
|
@@ -224,10 +225,10 @@ class ToolsView(QWidget):
|
|
|
224
225
|
toolbar.addWidget(check_btn)
|
|
225
226
|
self._check_btn = check_btn
|
|
226
227
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
toolbar.addWidget(
|
|
228
|
+
self._update_all_btn = QPushButton('Update All')
|
|
229
|
+
self._update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
|
|
230
|
+
self._update_all_btn.clicked.connect(self.update_all_requested.emit)
|
|
231
|
+
toolbar.addWidget(self._update_all_btn)
|
|
231
232
|
|
|
232
233
|
return toolbar
|
|
233
234
|
|
|
@@ -248,7 +249,10 @@ class ToolsView(QWidget):
|
|
|
248
249
|
background task so the widget tree renders immediately.
|
|
249
250
|
"""
|
|
250
251
|
self._refresh_in_progress = True
|
|
251
|
-
self.
|
|
252
|
+
self._scroll.hide()
|
|
253
|
+
self._loading_indicator.start()
|
|
254
|
+
self._check_btn.setEnabled(False)
|
|
255
|
+
self._update_all_btn.setEnabled(False)
|
|
252
256
|
need_deferred_check = False
|
|
253
257
|
|
|
254
258
|
try:
|
|
@@ -259,7 +263,10 @@ class ToolsView(QWidget):
|
|
|
259
263
|
logger.exception('Failed to refresh tools')
|
|
260
264
|
need_deferred_check = False
|
|
261
265
|
finally:
|
|
262
|
-
self.
|
|
266
|
+
self._loading_indicator.stop()
|
|
267
|
+
self._scroll.show()
|
|
268
|
+
self._check_btn.setEnabled(True)
|
|
269
|
+
self._update_all_btn.setEnabled(True)
|
|
263
270
|
self._refresh_in_progress = False
|
|
264
271
|
|
|
265
272
|
# Fire-and-forget: detect updates in the background, then patch
|
|
@@ -372,7 +379,7 @@ class ToolsView(QWidget):
|
|
|
372
379
|
"""Clear existing widgets and rebuild the tool/package tree."""
|
|
373
380
|
self._clear_section_widgets()
|
|
374
381
|
|
|
375
|
-
auto_update_map = self.
|
|
382
|
+
auto_update_map = self._store.config.plugin_auto_update or {}
|
|
376
383
|
kind_buckets = self._bucket_by_kind(
|
|
377
384
|
data.plugins,
|
|
378
385
|
data.packages_map,
|
|
@@ -439,7 +446,7 @@ class ToolsView(QWidget):
|
|
|
439
446
|
|
|
440
447
|
auto_val = auto_update_map.get(plugin.name, True)
|
|
441
448
|
plugin_updates = self._updates_available.get(plugin.name, {})
|
|
442
|
-
tool_timestamps = self.
|
|
449
|
+
tool_timestamps = self._store.config.last_tool_updates or {}
|
|
443
450
|
default_exe = data.default_runtime_executable
|
|
444
451
|
|
|
445
452
|
# Sort: default runtime first, then descending by tag
|
|
@@ -527,7 +534,7 @@ class ToolsView(QWidget):
|
|
|
527
534
|
plugin_manifest = data.manifest_packages.get(plugin.name, set())
|
|
528
535
|
raw_packages = data.packages_map.get(plugin.name, [])
|
|
529
536
|
display_packages = self._build_display_packages(raw_packages, plugin_manifest)
|
|
530
|
-
tool_timestamps = self.
|
|
537
|
+
tool_timestamps = self._store.config.last_tool_updates or {}
|
|
531
538
|
|
|
532
539
|
if display_packages:
|
|
533
540
|
for pkg in display_packages:
|
|
@@ -1062,7 +1069,7 @@ class ToolsView(QWidget):
|
|
|
1062
1069
|
|
|
1063
1070
|
def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None:
|
|
1064
1071
|
"""Persist the plugin-level auto-update toggle change to config."""
|
|
1065
|
-
mapping = dict(self.
|
|
1072
|
+
mapping = dict(self._store.config.plugin_auto_update or {})
|
|
1066
1073
|
|
|
1067
1074
|
if enabled:
|
|
1068
1075
|
mapping.pop(plugin_name, None)
|
|
@@ -1070,7 +1077,7 @@ class ToolsView(QWidget):
|
|
|
1070
1077
|
mapping[plugin_name] = False
|
|
1071
1078
|
|
|
1072
1079
|
new_value = mapping if mapping else None
|
|
1073
|
-
self.
|
|
1080
|
+
self._store.update(plugin_auto_update=new_value)
|
|
1074
1081
|
logger.info('Auto-update for %s set to %s', plugin_name, enabled)
|
|
1075
1082
|
|
|
1076
1083
|
def _on_package_auto_update_toggled(
|
|
@@ -1080,7 +1087,7 @@ class ToolsView(QWidget):
|
|
|
1080
1087
|
enabled: bool,
|
|
1081
1088
|
) -> None:
|
|
1082
1089
|
"""Persist a per-package auto-update override to the nested config dict."""
|
|
1083
|
-
mapping = dict(self.
|
|
1090
|
+
mapping = dict(self._store.config.plugin_auto_update or {})
|
|
1084
1091
|
current = mapping.get(plugin_name)
|
|
1085
1092
|
|
|
1086
1093
|
if isinstance(current, dict):
|
|
@@ -1096,7 +1103,7 @@ class ToolsView(QWidget):
|
|
|
1096
1103
|
mapping.pop(plugin_name, None)
|
|
1097
1104
|
|
|
1098
1105
|
new_value = mapping if mapping else None
|
|
1099
|
-
self.
|
|
1106
|
+
self._store.update(plugin_auto_update=new_value)
|
|
1100
1107
|
logger.info(
|
|
1101
1108
|
'Auto-update for %s/%s set to %s',
|
|
1102
1109
|
plugin_name,
|
|
@@ -1382,17 +1389,17 @@ class MainWindow(QMainWindow):
|
|
|
1382
1389
|
def __init__(
|
|
1383
1390
|
self,
|
|
1384
1391
|
porringer: API | None = None,
|
|
1385
|
-
|
|
1392
|
+
store: ConfigStore | None = None,
|
|
1386
1393
|
) -> None:
|
|
1387
1394
|
"""Initialize the main window.
|
|
1388
1395
|
|
|
1389
1396
|
Args:
|
|
1390
1397
|
porringer: Optional porringer API instance for manifest display.
|
|
1391
|
-
|
|
1398
|
+
store: The centralised :class:`ConfigStore`.
|
|
1392
1399
|
"""
|
|
1393
1400
|
super().__init__()
|
|
1394
1401
|
self._porringer = porringer
|
|
1395
|
-
self.
|
|
1402
|
+
self._store = store
|
|
1396
1403
|
self._coordinator: DataCoordinator | None = DataCoordinator(porringer) if porringer is not None else None
|
|
1397
1404
|
self.setWindowTitle('Synodic Client')
|
|
1398
1405
|
self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
|
|
@@ -1438,12 +1445,12 @@ class MainWindow(QMainWindow):
|
|
|
1438
1445
|
|
|
1439
1446
|
def show(self) -> None:
|
|
1440
1447
|
"""Show the window, initializing UI lazily on first show."""
|
|
1441
|
-
if self._tabs is None and self._porringer is not None and self.
|
|
1448
|
+
if self._tabs is None and self._porringer is not None and self._store is not None:
|
|
1442
1449
|
self._tabs = QTabWidget(self)
|
|
1443
1450
|
|
|
1444
1451
|
self._projects_view = ProjectsView(
|
|
1445
1452
|
self._porringer,
|
|
1446
|
-
self.
|
|
1453
|
+
self._store,
|
|
1447
1454
|
self,
|
|
1448
1455
|
coordinator=self._coordinator,
|
|
1449
1456
|
)
|
|
@@ -1451,7 +1458,7 @@ class MainWindow(QMainWindow):
|
|
|
1451
1458
|
|
|
1452
1459
|
self._tools_view = ToolsView(
|
|
1453
1460
|
self._porringer,
|
|
1454
|
-
self.
|
|
1461
|
+
self._store,
|
|
1455
1462
|
self,
|
|
1456
1463
|
coordinator=self._coordinator,
|
|
1457
1464
|
)
|
|
@@ -1500,16 +1507,16 @@ class Screen:
|
|
|
1500
1507
|
def __init__(
|
|
1501
1508
|
self,
|
|
1502
1509
|
porringer: API | None = None,
|
|
1503
|
-
|
|
1510
|
+
store: ConfigStore | None = None,
|
|
1504
1511
|
) -> None:
|
|
1505
1512
|
"""Initialize the screen.
|
|
1506
1513
|
|
|
1507
1514
|
Args:
|
|
1508
1515
|
porringer: Optional porringer API instance.
|
|
1509
|
-
|
|
1516
|
+
store: The centralised :class:`ConfigStore`.
|
|
1510
1517
|
"""
|
|
1511
1518
|
self._porringer = porringer
|
|
1512
|
-
self.
|
|
1519
|
+
self._store = store
|
|
1513
1520
|
|
|
1514
1521
|
@property
|
|
1515
1522
|
def window(self) -> MainWindow:
|
|
@@ -1519,5 +1526,5 @@ class Screen:
|
|
|
1519
1526
|
The MainWindow instance.
|
|
1520
1527
|
"""
|
|
1521
1528
|
if self._window is None:
|
|
1522
|
-
self._window = MainWindow(self._porringer, self.
|
|
1529
|
+
self._window = MainWindow(self._porringer, self._store)
|
|
1523
1530
|
return self._window
|
|
@@ -28,12 +28,12 @@ from PySide6.QtWidgets import (
|
|
|
28
28
|
QWidget,
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
+
from synodic_client.application.config_store import ConfigStore
|
|
31
32
|
from synodic_client.application.icon import app_icon
|
|
32
33
|
from synodic_client.application.screen import _format_relative_time
|
|
33
34
|
from synodic_client.application.screen.card import CardFrame
|
|
34
35
|
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
|
|
35
36
|
from synodic_client.logging import log_path, set_debug_level
|
|
36
|
-
from synodic_client.resolution import ResolvedConfig, update_user_config
|
|
37
37
|
from synodic_client.schema import GITHUB_REPO_URL
|
|
38
38
|
from synodic_client.startup import is_startup_registered, register_startup, remove_startup
|
|
39
39
|
|
|
@@ -43,14 +43,11 @@ logger = logging.getLogger(__name__)
|
|
|
43
43
|
class SettingsWindow(QMainWindow):
|
|
44
44
|
"""Application settings window with grouped card sections.
|
|
45
45
|
|
|
46
|
-
All controls persist changes immediately via
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
All controls persist changes immediately via the shared
|
|
47
|
+
:class:`ConfigStore`, which broadcasts the new :class:`ResolvedConfig`
|
|
48
|
+
to every connected consumer.
|
|
49
49
|
"""
|
|
50
50
|
|
|
51
|
-
settings_changed = Signal(object)
|
|
52
|
-
"""Emitted with the new ``ResolvedConfig`` whenever a setting is changed and persisted."""
|
|
53
|
-
|
|
54
51
|
check_updates_requested = Signal()
|
|
55
52
|
"""Emitted when the user clicks the *Check for Updates* button."""
|
|
56
53
|
|
|
@@ -74,19 +71,19 @@ class SettingsWindow(QMainWindow):
|
|
|
74
71
|
|
|
75
72
|
def __init__(
|
|
76
73
|
self,
|
|
77
|
-
|
|
74
|
+
store: ConfigStore,
|
|
78
75
|
version: str = '',
|
|
79
76
|
parent: QWidget | None = None,
|
|
80
77
|
) -> None:
|
|
81
78
|
"""Initialise the settings window.
|
|
82
79
|
|
|
83
80
|
Args:
|
|
84
|
-
|
|
81
|
+
store: The centralised configuration store.
|
|
85
82
|
version: The application version string to display.
|
|
86
83
|
parent: Optional parent widget.
|
|
87
84
|
"""
|
|
88
85
|
super().__init__(parent)
|
|
89
|
-
self.
|
|
86
|
+
self._store = store
|
|
90
87
|
self._version = version
|
|
91
88
|
self.setWindowTitle('Synodic Settings')
|
|
92
89
|
self.setMinimumSize(*SETTINGS_WINDOW_MIN_SIZE)
|
|
@@ -254,7 +251,7 @@ class SettingsWindow(QMainWindow):
|
|
|
254
251
|
|
|
255
252
|
Signals are blocked during the update to prevent feedback loops.
|
|
256
253
|
"""
|
|
257
|
-
config = self.
|
|
254
|
+
config = self._store.config
|
|
258
255
|
|
|
259
256
|
with self._block_signals():
|
|
260
257
|
# Channel: index 0 = Stable, 1 = Development
|
|
@@ -305,15 +302,6 @@ class SettingsWindow(QMainWindow):
|
|
|
305
302
|
"""Re-enable the *Check for Updates* button after a check completes."""
|
|
306
303
|
self._check_updates_btn.setEnabled(True)
|
|
307
304
|
|
|
308
|
-
def update_config(self, config: ResolvedConfig) -> None:
|
|
309
|
-
"""Replace the internal config snapshot without emitting signals.
|
|
310
|
-
|
|
311
|
-
Called by controllers that persist timestamps so that the next
|
|
312
|
-
:meth:`sync_from_config` sees fresh data instead of the stale
|
|
313
|
-
snapshot captured at construction time.
|
|
314
|
-
"""
|
|
315
|
-
self._config = config
|
|
316
|
-
|
|
317
305
|
def set_last_checked(self, timestamp: str) -> None:
|
|
318
306
|
"""Update the *last updated* label from an ISO 8601 timestamp."""
|
|
319
307
|
relative = _format_relative_time(timestamp)
|
|
@@ -331,7 +319,11 @@ class SettingsWindow(QMainWindow):
|
|
|
331
319
|
# adjustSize() only reaches the minimum. Compute the ideal
|
|
332
320
|
# height from the content widget directly.
|
|
333
321
|
content_hint = self._scroll_content.sizeHint()
|
|
334
|
-
|
|
322
|
+
layout = self._scroll_content.layout()
|
|
323
|
+
if layout is None:
|
|
324
|
+
super().show()
|
|
325
|
+
return
|
|
326
|
+
margins = layout.contentsMargins()
|
|
335
327
|
ideal_w = max(content_hint.width() + margins.left() + margins.right(), self.minimumWidth())
|
|
336
328
|
ideal_h = max(content_hint.height() + margins.top() + margins.bottom(), self.minimumHeight())
|
|
337
329
|
self.resize(ideal_w, ideal_h)
|
|
@@ -349,8 +341,7 @@ class SettingsWindow(QMainWindow):
|
|
|
349
341
|
Args:
|
|
350
342
|
**changes: Field-name / value pairs to persist.
|
|
351
343
|
"""
|
|
352
|
-
self.
|
|
353
|
-
self.settings_changed.emit(self._config)
|
|
344
|
+
self._store.update(**changes)
|
|
354
345
|
|
|
355
346
|
@contextmanager
|
|
356
347
|
def _block_signals(self) -> Iterator[None]:
|
|
@@ -406,13 +397,12 @@ class SettingsWindow(QMainWindow):
|
|
|
406
397
|
self._persist(auto_apply=checked)
|
|
407
398
|
|
|
408
399
|
def _on_auto_start_changed(self, checked: bool) -> None:
|
|
409
|
-
self.
|
|
400
|
+
self._store.update(auto_start=checked)
|
|
410
401
|
if getattr(sys, 'frozen', False):
|
|
411
402
|
if checked:
|
|
412
403
|
register_startup(sys.executable)
|
|
413
404
|
else:
|
|
414
405
|
remove_startup()
|
|
415
|
-
self.settings_changed.emit(self._config)
|
|
416
406
|
|
|
417
407
|
def _on_debug_logging_changed(self, checked: bool) -> None:
|
|
418
408
|
set_debug_level(enabled=checked)
|