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.
Files changed (78) hide show
  1. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/bootstrap.py +2 -0
  4. synodic_client-0.0.1.dev64/synodic_client/application/config_store.py +65 -0
  5. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/qt.py +36 -3
  6. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/schema.py +21 -0
  7. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/projects.py +16 -11
  8. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/screen.py +36 -29
  9. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/settings.py +15 -25
  10. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/spinner.py +46 -36
  11. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tool_update_controller.py +119 -85
  12. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tray.py +19 -27
  13. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/update_banner.py +25 -17
  14. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/theme.py +1 -0
  15. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/update_controller.py +38 -27
  16. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/workers.py +20 -7
  17. synodic_client-0.0.1.dev64/synodic_client/subprocess_patch.py +82 -0
  18. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_gather_packages.py +38 -32
  19. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_settings.py +39 -70
  20. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_tray_window_show.py +25 -7
  21. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_controller.py +30 -35
  22. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/LICENSE.md +0 -0
  23. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/README.md +0 -0
  24. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/__main__.py +0 -0
  26. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/__init__.py +0 -0
  27. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/data.py +0 -0
  28. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/icon.py +0 -0
  29. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/init.py +0 -0
  30. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/instance.py +0 -0
  31. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/__init__.py +0 -0
  32. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/action_card.py +0 -0
  33. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/card.py +0 -0
  34. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install.py +0 -0
  35. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install_workers.py +0 -0
  36. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/log_panel.py +0 -0
  37. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/plugin_row.py +0 -0
  38. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/schema.py +0 -0
  39. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/sidebar.py +0 -0
  40. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/application/uri.py +0 -0
  41. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/cli.py +0 -0
  42. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/config.py +0 -0
  44. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/logging.py +0 -0
  45. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/protocol.py +0 -0
  46. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/py.typed +0 -0
  47. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/resolution.py +0 -0
  48. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/schema.py +0 -0
  49. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/startup.py +0 -0
  50. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/synodic_client/updater.py +0 -0
  51. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/conftest.py +0 -0
  53. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/conftest.py +0 -0
  56. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_action_card.py +0 -0
  57. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_install_preview.py +0 -0
  58. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_log_panel.py +0 -0
  59. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_logging.py +0 -0
  60. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_preview_model.py +0 -0
  61. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_sidebar.py +0 -0
  62. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_banner.py +0 -0
  63. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_feedback.py +0 -0
  64. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_cli.py +0 -0
  65. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_client_updater.py +0 -0
  66. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_client_version.py +0 -0
  67. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_config.py +0 -0
  68. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_examples.py +0 -0
  69. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_init.py +0 -0
  70. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_install.py +0 -0
  71. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_resolution.py +0 -0
  72. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_updater.py +0 -0
  73. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_uri.py +0 -0
  74. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/test_workers.py +0 -0
  75. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/__init__.py +0 -0
  76. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/conftest.py +0 -0
  77. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/test_protocol.py +0 -0
  78. {synodic_client-0.0.1.dev62 → synodic_client-0.0.1.dev64}/tests/unit/windows/test_startup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: synodic_client
3
- Version: 0.0.1.dev62
3
+ Version: 0.0.1.dev64
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
@@ -15,7 +15,7 @@ dependencies = [
15
15
  "velopack>=0.0.1444.dev49733",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev62"
18
+ version = "0.0.1.dev64"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -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
- _screen = Screen(porringer, config)
201
- _tray = TrayScreen(app, client, _screen.window, config=config)
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:
@@ -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 SpinnerWidget
26
+ from synodic_client.application.screen.spinner import LoadingIndicator
26
27
  from synodic_client.application.theme import COMPACT_MARGINS
27
- from synodic_client.resolution import ResolvedConfig
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
- config: ResolvedConfig,
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
- config: Resolved configuration.
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._config = config
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
- outer.addLayout(right, stretch=1)
97
+ self._loading_indicator = LoadingIndicator('Loading projects\u2026')
98
+ self._stack.addWidget(self._loading_indicator)
95
99
 
96
- self._loading_spinner = SpinnerWidget('Loading projects\u2026', parent=self)
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._loading_spinner.start()
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._config.detect_updates,
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._loading_spinner.stop()
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._config,
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 SpinnerWidget
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
- config: ResolvedConfig,
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
- config: Resolved configuration (for auto-update toggles).
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._config = config
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._loading_spinner = SpinnerWidget('Loading tools\u2026', parent=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
- update_all_btn = QPushButton('Update All')
228
- update_all_btn.setToolTip('Upgrade all auto-update-enabled plugins now')
229
- update_all_btn.clicked.connect(self.update_all_requested.emit)
230
- toolbar.addWidget(update_all_btn)
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._loading_spinner.start()
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._loading_spinner.stop()
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._config.plugin_auto_update or {}
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._config.last_tool_updates or {}
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._config.last_tool_updates or {}
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._config.plugin_auto_update or {})
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._config = update_user_config(plugin_auto_update=new_value)
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._config.plugin_auto_update or {})
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._config = update_user_config(plugin_auto_update=new_value)
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
- config: ResolvedConfig | None = None,
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
- config: Resolved configuration for plugin auto-update state.
1398
+ store: The centralised :class:`ConfigStore`.
1392
1399
  """
1393
1400
  super().__init__()
1394
1401
  self._porringer = porringer
1395
- self._config = config
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._config is not None:
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._config,
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._config,
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
- config: ResolvedConfig | None = None,
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
- config: Resolved configuration.
1516
+ store: The centralised :class:`ConfigStore`.
1510
1517
  """
1511
1518
  self._porringer = porringer
1512
- self._config = config
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._config)
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 :func:`update_user_config`
47
- and emit :attr:`settings_changed` so that the tray and updater can
48
- react. The signal carries the new :class:`ResolvedConfig`.
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
- config: ResolvedConfig,
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
- config: The current resolved configuration snapshot.
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._config = config
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._config
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
- margins = self._scroll_content.layout().contentsMargins()
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._config = update_user_config(**changes)
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._config = update_user_config(auto_start=checked)
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)