synodic-client 0.0.1.dev63__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.dev63 → synodic_client-0.0.1.dev64}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/pyproject.toml +1 -1
  3. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/bootstrap.py +1 -1
  4. synodic_client-0.0.1.dev64/synodic_client/application/config_store.py +65 -0
  5. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/qt.py +5 -3
  6. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/schema.py +21 -0
  7. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/projects.py +9 -6
  8. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/screen.py +21 -21
  9. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/settings.py +15 -25
  10. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tool_update_controller.py +91 -85
  11. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/tray.py +14 -28
  12. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/update_banner.py +25 -17
  13. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/update_controller.py +22 -27
  14. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/workers.py +15 -14
  15. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/subprocess_patch.py +5 -5
  16. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_gather_packages.py +38 -32
  17. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_settings.py +39 -70
  18. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_tray_window_show.py +25 -7
  19. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_controller.py +30 -35
  20. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/LICENSE.md +0 -0
  21. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/README.md +0 -0
  22. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/__init__.py +0 -0
  23. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/__main__.py +0 -0
  24. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/data.py +0 -0
  26. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/icon.py +0 -0
  27. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/init.py +0 -0
  28. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/instance.py +0 -0
  29. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/__init__.py +0 -0
  30. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/action_card.py +0 -0
  31. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/card.py +0 -0
  32. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install.py +0 -0
  33. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/install_workers.py +0 -0
  34. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/log_panel.py +0 -0
  35. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/plugin_row.py +0 -0
  36. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/schema.py +0 -0
  37. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/sidebar.py +0 -0
  38. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/screen/spinner.py +0 -0
  39. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/theme.py +0 -0
  40. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/application/uri.py +0 -0
  41. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/cli.py +0 -0
  42. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/client.py +0 -0
  43. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/config.py +0 -0
  44. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/logging.py +0 -0
  45. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/protocol.py +0 -0
  46. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/py.typed +0 -0
  47. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/resolution.py +0 -0
  48. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/schema.py +0 -0
  49. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/startup.py +0 -0
  50. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/synodic_client/updater.py +0 -0
  51. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/__init__.py +0 -0
  52. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/conftest.py +0 -0
  53. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/__init__.py +0 -0
  55. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/conftest.py +0 -0
  56. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_action_card.py +0 -0
  57. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_install_preview.py +0 -0
  58. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_log_panel.py +0 -0
  59. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_logging.py +0 -0
  60. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_preview_model.py +0 -0
  61. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_sidebar.py +0 -0
  62. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_banner.py +0 -0
  63. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/qt/test_update_feedback.py +0 -0
  64. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_cli.py +0 -0
  65. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_client_updater.py +0 -0
  66. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_client_version.py +0 -0
  67. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_config.py +0 -0
  68. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_examples.py +0 -0
  69. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_init.py +0 -0
  70. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_install.py +0 -0
  71. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_resolution.py +0 -0
  72. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_updater.py +0 -0
  73. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_uri.py +0 -0
  74. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/test_workers.py +0 -0
  75. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/windows/__init__.py +0 -0
  76. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/windows/conftest.py +0 -0
  77. {synodic_client-0.0.1.dev63 → synodic_client-0.0.1.dev64}/tests/unit/windows/test_protocol.py +0 -0
  78. {synodic_client-0.0.1.dev63 → 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.dev63
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.dev63"
18
+ version = "0.0.1.dev64"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -17,8 +17,8 @@ import sys
17
17
 
18
18
  from synodic_client.config import set_dev_mode
19
19
  from synodic_client.logging import configure_logging
20
- from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
21
20
  from synodic_client.protocol import extract_uri_from_args
21
+ from synodic_client.subprocess_patch import apply as _apply_subprocess_patch
22
22
  from synodic_client.updater import initialize_velopack
23
23
 
24
24
  # Parse flags early so logging uses the right filename and level.
@@ -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
@@ -216,8 +217,9 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
216
217
  sys.exit(0)
217
218
  instance.start_server()
218
219
 
219
- _screen = Screen(porringer, config)
220
- _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)
221
223
 
222
224
  # Keep install preview windows alive until the app exits
223
225
  _install_windows: list[InstallPreviewWindow] = []
@@ -227,7 +229,7 @@ def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool =
227
229
  window = InstallPreviewWindow(
228
230
  porringer,
229
231
  manifest_url,
230
- config=config,
232
+ config=_store.config,
231
233
  )
232
234
  _install_windows.append(window)
233
235
  window.show()
@@ -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
@@ -24,7 +25,9 @@ from synodic_client.application.screen.schema import PreviewPhase
24
25
  from synodic_client.application.screen.sidebar import ManifestSidebar
25
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
@@ -163,7 +166,7 @@ class ProjectsView(QWidget):
163
166
  widget.load(
164
167
  str(path),
165
168
  project_directory=path if path.is_dir() else path.parent,
166
- detect_updates=self._config.detect_updates,
169
+ detect_updates=self._store.config.detect_updates,
167
170
  )
168
171
 
169
172
  except Exception:
@@ -196,7 +199,7 @@ class ProjectsView(QWidget):
196
199
  self._porringer,
197
200
  self,
198
201
  show_close=False,
199
- config=self._config,
202
+ config=self._store.config,
200
203
  )
201
204
  widget._discovered_plugins = discovered
202
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 (
@@ -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] = {}
@@ -379,7 +379,7 @@ class ToolsView(QWidget):
379
379
  """Clear existing widgets and rebuild the tool/package tree."""
380
380
  self._clear_section_widgets()
381
381
 
382
- auto_update_map = self._config.plugin_auto_update or {}
382
+ auto_update_map = self._store.config.plugin_auto_update or {}
383
383
  kind_buckets = self._bucket_by_kind(
384
384
  data.plugins,
385
385
  data.packages_map,
@@ -446,7 +446,7 @@ class ToolsView(QWidget):
446
446
 
447
447
  auto_val = auto_update_map.get(plugin.name, True)
448
448
  plugin_updates = self._updates_available.get(plugin.name, {})
449
- tool_timestamps = self._config.last_tool_updates or {}
449
+ tool_timestamps = self._store.config.last_tool_updates or {}
450
450
  default_exe = data.default_runtime_executable
451
451
 
452
452
  # Sort: default runtime first, then descending by tag
@@ -534,7 +534,7 @@ class ToolsView(QWidget):
534
534
  plugin_manifest = data.manifest_packages.get(plugin.name, set())
535
535
  raw_packages = data.packages_map.get(plugin.name, [])
536
536
  display_packages = self._build_display_packages(raw_packages, plugin_manifest)
537
- tool_timestamps = self._config.last_tool_updates or {}
537
+ tool_timestamps = self._store.config.last_tool_updates or {}
538
538
 
539
539
  if display_packages:
540
540
  for pkg in display_packages:
@@ -1069,7 +1069,7 @@ class ToolsView(QWidget):
1069
1069
 
1070
1070
  def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None:
1071
1071
  """Persist the plugin-level auto-update toggle change to config."""
1072
- mapping = dict(self._config.plugin_auto_update or {})
1072
+ mapping = dict(self._store.config.plugin_auto_update or {})
1073
1073
 
1074
1074
  if enabled:
1075
1075
  mapping.pop(plugin_name, None)
@@ -1077,7 +1077,7 @@ class ToolsView(QWidget):
1077
1077
  mapping[plugin_name] = False
1078
1078
 
1079
1079
  new_value = mapping if mapping else None
1080
- self._config = update_user_config(plugin_auto_update=new_value)
1080
+ self._store.update(plugin_auto_update=new_value)
1081
1081
  logger.info('Auto-update for %s set to %s', plugin_name, enabled)
1082
1082
 
1083
1083
  def _on_package_auto_update_toggled(
@@ -1087,7 +1087,7 @@ class ToolsView(QWidget):
1087
1087
  enabled: bool,
1088
1088
  ) -> None:
1089
1089
  """Persist a per-package auto-update override to the nested config dict."""
1090
- mapping = dict(self._config.plugin_auto_update or {})
1090
+ mapping = dict(self._store.config.plugin_auto_update or {})
1091
1091
  current = mapping.get(plugin_name)
1092
1092
 
1093
1093
  if isinstance(current, dict):
@@ -1103,7 +1103,7 @@ class ToolsView(QWidget):
1103
1103
  mapping.pop(plugin_name, None)
1104
1104
 
1105
1105
  new_value = mapping if mapping else None
1106
- self._config = update_user_config(plugin_auto_update=new_value)
1106
+ self._store.update(plugin_auto_update=new_value)
1107
1107
  logger.info(
1108
1108
  'Auto-update for %s/%s set to %s',
1109
1109
  plugin_name,
@@ -1389,17 +1389,17 @@ class MainWindow(QMainWindow):
1389
1389
  def __init__(
1390
1390
  self,
1391
1391
  porringer: API | None = None,
1392
- config: ResolvedConfig | None = None,
1392
+ store: ConfigStore | None = None,
1393
1393
  ) -> None:
1394
1394
  """Initialize the main window.
1395
1395
 
1396
1396
  Args:
1397
1397
  porringer: Optional porringer API instance for manifest display.
1398
- config: Resolved configuration for plugin auto-update state.
1398
+ store: The centralised :class:`ConfigStore`.
1399
1399
  """
1400
1400
  super().__init__()
1401
1401
  self._porringer = porringer
1402
- self._config = config
1402
+ self._store = store
1403
1403
  self._coordinator: DataCoordinator | None = DataCoordinator(porringer) if porringer is not None else None
1404
1404
  self.setWindowTitle('Synodic Client')
1405
1405
  self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
@@ -1445,12 +1445,12 @@ class MainWindow(QMainWindow):
1445
1445
 
1446
1446
  def show(self) -> None:
1447
1447
  """Show the window, initializing UI lazily on first show."""
1448
- 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:
1449
1449
  self._tabs = QTabWidget(self)
1450
1450
 
1451
1451
  self._projects_view = ProjectsView(
1452
1452
  self._porringer,
1453
- self._config,
1453
+ self._store,
1454
1454
  self,
1455
1455
  coordinator=self._coordinator,
1456
1456
  )
@@ -1458,7 +1458,7 @@ class MainWindow(QMainWindow):
1458
1458
 
1459
1459
  self._tools_view = ToolsView(
1460
1460
  self._porringer,
1461
- self._config,
1461
+ self._store,
1462
1462
  self,
1463
1463
  coordinator=self._coordinator,
1464
1464
  )
@@ -1507,16 +1507,16 @@ class Screen:
1507
1507
  def __init__(
1508
1508
  self,
1509
1509
  porringer: API | None = None,
1510
- config: ResolvedConfig | None = None,
1510
+ store: ConfigStore | None = None,
1511
1511
  ) -> None:
1512
1512
  """Initialize the screen.
1513
1513
 
1514
1514
  Args:
1515
1515
  porringer: Optional porringer API instance.
1516
- config: Resolved configuration.
1516
+ store: The centralised :class:`ConfigStore`.
1517
1517
  """
1518
1518
  self._porringer = porringer
1519
- self._config = config
1519
+ self._store = store
1520
1520
 
1521
1521
  @property
1522
1522
  def window(self) -> MainWindow:
@@ -1526,5 +1526,5 @@ class Screen:
1526
1526
  The MainWindow instance.
1527
1527
  """
1528
1528
  if self._window is None:
1529
- self._window = MainWindow(self._porringer, self._config)
1529
+ self._window = MainWindow(self._porringer, self._store)
1530
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)