synodic-client 0.0.1.dev33__tar.gz → 0.0.1.dev35__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 (59) hide show
  1. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/pyproject.toml +2 -2
  3. synodic_client-0.0.1.dev35/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/bootstrap.py +5 -2
  5. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/qt.py +15 -7
  6. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/action_card.py +17 -1
  7. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/install.py +13 -10
  8. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/screen.py +14 -19
  9. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/settings.py +34 -38
  10. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/tray.py +16 -10
  11. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/config.py +67 -60
  12. synodic_client-0.0.1.dev35/synodic_client/resolution.py +265 -0
  13. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/updater.py +17 -1
  14. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/test_settings.py +47 -32
  15. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_config.py +45 -52
  16. synodic_client-0.0.1.dev35/tests/unit/test_resolution.py +407 -0
  17. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_updater.py +20 -0
  18. synodic_client-0.0.1.dev33/synodic_client/_version.py +0 -1
  19. synodic_client-0.0.1.dev33/synodic_client/resolution.py +0 -166
  20. synodic_client-0.0.1.dev33/tests/unit/test_resolution.py +0 -414
  21. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/LICENSE.md +0 -0
  22. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/README.md +0 -0
  23. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/__init__.py +0 -0
  24. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/__main__.py +0 -0
  25. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/__init__.py +0 -0
  26. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/icon.py +0 -0
  27. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/instance.py +0 -0
  28. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/__init__.py +0 -0
  29. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/card.py +0 -0
  30. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/log_panel.py +0 -0
  31. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/screen/spinner.py +0 -0
  32. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/theme.py +0 -0
  33. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/uri.py +0 -0
  34. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/application/workers.py +0 -0
  35. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/cli.py +0 -0
  36. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/client.py +0 -0
  37. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/logging.py +0 -0
  38. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/protocol.py +0 -0
  39. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/py.typed +0 -0
  40. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/synodic_client/startup.py +0 -0
  41. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/conftest.py +0 -0
  43. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/__init__.py +0 -0
  45. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/conftest.py +0 -0
  46. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/test_action_card.py +0 -0
  47. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/test_install_preview.py +0 -0
  48. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/test_log_panel.py +0 -0
  49. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/qt/test_logging.py +0 -0
  50. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_cli.py +0 -0
  51. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_client_updater.py +0 -0
  52. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_client_version.py +0 -0
  53. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_examples.py +0 -0
  54. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_install.py +0 -0
  55. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/test_uri.py +0 -0
  56. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/windows/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/windows/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/tests/unit/windows/test_protocol.py +0 -0
  59. {synodic_client-0.0.1.dev33 → synodic_client-0.0.1.dev35}/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.dev33
3
+ Version: 0.0.1.dev35
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
@@ -8,7 +8,7 @@ Project-URL: repository, https://github.com/synodic/synodic-client
8
8
  Requires-Python: <3.15,>=3.14
9
9
  Requires-Dist: pyside6>=6.10.2
10
10
  Requires-Dist: packaging>=26.0
11
- Requires-Dist: porringer>=0.2.1.dev51
11
+ Requires-Dist: porringer>=0.2.1.dev52
12
12
  Requires-Dist: qasync>=0.28.0
13
13
  Requires-Dist: velopack>=0.0.1442.dev64255
14
14
  Requires-Dist: typer>=0.24.1
@@ -10,12 +10,12 @@ requires-python = ">=3.14, <3.15"
10
10
  dependencies = [
11
11
  "pyside6>=6.10.2",
12
12
  "packaging>=26.0",
13
- "porringer>=0.2.1.dev51",
13
+ "porringer>=0.2.1.dev52",
14
14
  "qasync>=0.28.0",
15
15
  "velopack>=0.0.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev33"
18
+ version = "0.0.1.dev35"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev35'
@@ -18,7 +18,7 @@ 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 register_protocol
21
- from synodic_client.resolution import resolve_auto_start, resolve_config
21
+ from synodic_client.resolution import resolve_config, seed_user_config_from_build
22
22
  from synodic_client.startup import register_startup, remove_startup
23
23
  from synodic_client.updater import initialize_velopack
24
24
 
@@ -32,10 +32,13 @@ configure_logging()
32
32
  initialize_velopack()
33
33
 
34
34
  if not _dev_mode:
35
+ # Seed user config from the build config (one-time propagation).
36
+ seed_user_config_from_build()
37
+
35
38
  register_protocol(sys.executable)
36
39
 
37
40
  _config = resolve_config()
38
- if resolve_auto_start(_config):
41
+ if _config.auto_start:
39
42
  register_startup(sys.executable)
40
43
  else:
41
44
  remove_startup()
@@ -21,15 +21,21 @@ from synodic_client.application.screen.screen import Screen
21
21
  from synodic_client.application.screen.tray import TrayScreen
22
22
  from synodic_client.application.uri import parse_uri
23
23
  from synodic_client.client import Client
24
- from synodic_client.config import GlobalConfiguration, set_dev_mode
24
+ from synodic_client.config import set_dev_mode
25
25
  from synodic_client.logging import configure_logging
26
26
  from synodic_client.protocol import register_protocol
27
- from synodic_client.resolution import resolve_auto_start, resolve_config, resolve_update_config
27
+ from synodic_client.resolution import (
28
+ ResolvedConfig,
29
+ resolve_config,
30
+ resolve_update_config,
31
+ resolve_version,
32
+ seed_user_config_from_build,
33
+ )
28
34
  from synodic_client.startup import register_startup, remove_startup
29
35
  from synodic_client.updater import initialize_velopack
30
36
 
31
37
 
32
- def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfiguration]:
38
+ def _init_services(logger: logging.Logger) -> tuple[Client, API, ResolvedConfig]:
33
39
  """Create and configure core services.
34
40
 
35
41
  Returns:
@@ -47,11 +53,10 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura
47
53
  cached_dirs = porringer.cache.list_directories()
48
54
 
49
55
  logger.info(
50
- 'Synodic Client v%s started (channel: %s, source: %s, config_fields_set: %s, cached_projects: %d)',
51
- client.version,
56
+ 'Synodic Client v%s started (channel: %s, source: %s, cached_projects: %d)',
57
+ resolve_version(client),
52
58
  update_config.channel.name,
53
59
  update_config.repo_url,
54
- sorted(config.model_fields_set),
55
60
  len(cached_dirs),
56
61
  )
57
62
 
@@ -140,8 +145,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
140
145
  initialize_velopack()
141
146
  register_protocol(sys.executable)
142
147
 
148
+ # Seed user config from build config (one-time propagation).
149
+ seed_user_config_from_build()
150
+
143
151
  startup_config = resolve_config()
144
- if resolve_auto_start(startup_config):
152
+ if startup_config.auto_start:
145
153
  register_startup(sys.executable)
146
154
  else:
147
155
  remove_startup()
@@ -297,6 +297,9 @@ class ActionCard(QFrame):
297
297
 
298
298
  self._package_label = QLabel()
299
299
  self._package_label.setStyleSheet(ACTION_CARD_PACKAGE_STYLE)
300
+ self._package_label.setTextInteractionFlags(
301
+ Qt.TextInteractionFlag.TextSelectableByMouse,
302
+ )
300
303
  top.addWidget(self._package_label)
301
304
 
302
305
  top.addStretch()
@@ -327,6 +330,9 @@ class ActionCard(QFrame):
327
330
  self._desc_label = QLabel()
328
331
  self._desc_label.setStyleSheet(ACTION_CARD_DESC_STYLE)
329
332
  self._desc_label.setWordWrap(True)
333
+ self._desc_label.setTextInteractionFlags(
334
+ Qt.TextInteractionFlag.TextSelectableByMouse,
335
+ )
330
336
  return self._desc_label
331
337
 
332
338
  def _build_command_row(self) -> QWidget:
@@ -377,9 +383,13 @@ class ActionCard(QFrame):
377
383
  """Toggle the inline log body on click."""
378
384
  if self._is_skeleton or not hasattr(self, '_log_output'):
379
385
  return
380
- # Don't toggle the log when clicking the copy button
386
+ # Don't toggle the log when clicking interactive child widgets
381
387
  if hasattr(self, '_copy_btn') and self._copy_btn.underMouse():
382
388
  return
389
+ if hasattr(self, '_package_label') and self._package_label.underMouse():
390
+ return
391
+ if hasattr(self, '_desc_label') and self._desc_label.underMouse():
392
+ return
383
393
  self._toggle_log()
384
394
 
385
395
  def _toggle_log(self) -> None:
@@ -560,6 +570,12 @@ class ActionCard(QFrame):
560
570
  self._status_label.setText(label)
561
571
  self._status_label.setStyleSheet(ACTION_CARD_STATUS_NEEDED)
562
572
 
573
+ # Surface diagnostic detail (e.g. SCM URL mismatch) as a tooltip
574
+ if result.message:
575
+ self._status_label.setToolTip(result.message)
576
+ else:
577
+ self._status_label.setToolTip('')
578
+
563
579
  # Version column
564
580
  self._check_available_version = result.available_version
565
581
  if result.installed_version and result.available_version:
@@ -72,7 +72,7 @@ from synodic_client.application.theme import (
72
72
  MUTED_STYLE,
73
73
  NO_MARGINS,
74
74
  )
75
- from synodic_client.config import GlobalConfiguration, save_config
75
+ from synodic_client.resolution import ResolvedConfig, update_user_config
76
76
 
77
77
  logger = logging.getLogger(__name__)
78
78
 
@@ -332,7 +332,7 @@ class SetupPreviewWidget(QWidget):
332
332
  parent: QWidget | None = None,
333
333
  *,
334
334
  show_close: bool = True,
335
- config: GlobalConfiguration | None = None,
335
+ config: ResolvedConfig | None = None,
336
336
  ) -> None:
337
337
  """Initialize the preview widget.
338
338
 
@@ -579,14 +579,14 @@ class SetupPreviewWidget(QWidget):
579
579
  if self._config is None or self._manifest_key is None:
580
580
  return
581
581
 
582
- pkgs = self._config.prerelease_packages or {}
582
+ pkgs = dict(self._config.prerelease_packages or {})
583
583
  if self._prerelease_overrides:
584
584
  pkgs[self._manifest_key] = sorted(self._prerelease_overrides)
585
585
  else:
586
586
  pkgs.pop(self._manifest_key, None)
587
587
 
588
- self._config.prerelease_packages = pkgs if pkgs else None
589
- save_config(self._config)
588
+ new_value = pkgs if pkgs else None
589
+ self._config = update_user_config(prerelease_packages=new_value)
590
590
  logger.info('Pre-release overrides for %s: %s', self._manifest_key, self._prerelease_overrides)
591
591
 
592
592
  if not self._installing:
@@ -918,7 +918,7 @@ class InstallPreviewWindow(QMainWindow):
918
918
  manifest_url: str,
919
919
  parent: QWidget | None = None,
920
920
  *,
921
- config: GlobalConfiguration | None = None,
921
+ config: ResolvedConfig | None = None,
922
922
  ) -> None:
923
923
  """Initialize the install preview window.
924
924
 
@@ -926,13 +926,13 @@ class InstallPreviewWindow(QMainWindow):
926
926
  porringer: The porringer API instance.
927
927
  manifest_url: The URL of the manifest to install.
928
928
  parent: Optional parent widget.
929
- config: Resolved global configuration for per-manifest pre-release
929
+ config: Resolved configuration for per-manifest pre-release
930
930
  state and update detection flags.
931
931
  """
932
932
  super().__init__(parent)
933
933
  self._porringer = porringer
934
934
  self._manifest_url = manifest_url
935
- self._config = config or GlobalConfiguration()
935
+ self._config = config
936
936
  self._temp_dir_path: str | None = None
937
937
  self._runner: QThread | None = None
938
938
 
@@ -1038,13 +1038,16 @@ class InstallPreviewWindow(QMainWindow):
1038
1038
  self._preview_widget.set_manifest_key(self._manifest_url)
1039
1039
 
1040
1040
  manifest_key = normalize_manifest_key(self._manifest_url)
1041
- overrides = set((self._config.prerelease_packages or {}).get(manifest_key, []))
1041
+ config = self._config
1042
+ if config is None:
1043
+ return
1044
+ overrides = set((config.prerelease_packages or {}).get(manifest_key, []))
1042
1045
 
1043
1046
  preview_worker = PreviewWorker(
1044
1047
  self._porringer,
1045
1048
  self._manifest_url,
1046
1049
  project_directory=self._project_directory,
1047
- detect_updates=self._config.detect_updates,
1050
+ detect_updates=config.detect_updates,
1048
1051
  prerelease_packages=overrides or None,
1049
1052
  )
1050
1053
 
@@ -53,7 +53,7 @@ from synodic_client.application.theme import (
53
53
  PLUGIN_UPDATE_STYLE,
54
54
  SETTINGS_GEAR_STYLE,
55
55
  )
56
- from synodic_client.config import GlobalConfiguration, save_config
56
+ from synodic_client.resolution import ResolvedConfig, update_user_config
57
57
 
58
58
  logger = logging.getLogger(__name__)
59
59
 
@@ -306,14 +306,14 @@ class PluginsView(QWidget):
306
306
  def __init__(
307
307
  self,
308
308
  porringer: API,
309
- config: GlobalConfiguration,
309
+ config: ResolvedConfig,
310
310
  parent: QWidget | None = None,
311
311
  ) -> None:
312
312
  """Initialize the plugins view.
313
313
 
314
314
  Args:
315
315
  porringer: The porringer API instance.
316
- config: Resolved global configuration (for auto-update toggles).
316
+ config: Resolved configuration (for auto-update toggles).
317
317
  parent: Optional parent widget.
318
318
  """
319
319
  super().__init__(parent)
@@ -484,10 +484,7 @@ class PluginsView(QWidget):
484
484
 
485
485
  def _on_auto_update_toggled(self, plugin_name: str, enabled: bool) -> None:
486
486
  """Persist the auto-update toggle change to config."""
487
- mapping = self._config.plugin_auto_update
488
- if mapping is None:
489
- mapping = {}
490
- self._config.plugin_auto_update = mapping
487
+ mapping = dict(self._config.plugin_auto_update or {})
491
488
 
492
489
  if enabled:
493
490
  mapping.pop(plugin_name, None)
@@ -495,10 +492,8 @@ class PluginsView(QWidget):
495
492
  mapping[plugin_name] = False
496
493
 
497
494
  # Clean up the dict if all plugins are enabled
498
- if not mapping:
499
- self._config.plugin_auto_update = None
500
-
501
- save_config(self._config)
495
+ new_value = mapping if mapping else None
496
+ self._config = update_user_config(plugin_auto_update=new_value)
502
497
  logger.info('Auto-update for %s set to %s', plugin_name, enabled)
503
498
 
504
499
 
@@ -510,12 +505,12 @@ class ProjectsView(QWidget):
510
505
  install execution.
511
506
  """
512
507
 
513
- def __init__(self, porringer: API, config: GlobalConfiguration, parent: QWidget | None = None) -> None:
508
+ def __init__(self, porringer: API, config: ResolvedConfig, parent: QWidget | None = None) -> None:
514
509
  """Initialize the projects view.
515
510
 
516
511
  Args:
517
512
  porringer: The porringer API instance.
518
- config: Resolved global configuration.
513
+ config: Resolved configuration.
519
514
  parent: Optional parent widget.
520
515
  """
521
516
  super().__init__(parent)
@@ -805,17 +800,17 @@ class MainWindow(QMainWindow):
805
800
  def __init__(
806
801
  self,
807
802
  porringer: API | None = None,
808
- config: GlobalConfiguration | None = None,
803
+ config: ResolvedConfig | None = None,
809
804
  ) -> None:
810
805
  """Initialize the main window.
811
806
 
812
807
  Args:
813
808
  porringer: Optional porringer API instance for manifest display.
814
- config: Resolved global configuration for plugin auto-update state.
809
+ config: Resolved configuration for plugin auto-update state.
815
810
  """
816
811
  super().__init__()
817
812
  self._porringer = porringer
818
- self._config = config or GlobalConfiguration()
813
+ self._config = config
819
814
  self.setWindowTitle('Synodic Client')
820
815
  self.setMinimumSize(*MAIN_WINDOW_MIN_SIZE)
821
816
  self.setWindowIcon(app_icon())
@@ -832,7 +827,7 @@ class MainWindow(QMainWindow):
832
827
 
833
828
  def show(self) -> None:
834
829
  """Show the window, initializing UI lazily on first show."""
835
- if self._tabs is None and self._porringer is not None:
830
+ if self._tabs is None and self._porringer is not None and self._config is not None:
836
831
  self._tabs = QTabWidget(self)
837
832
 
838
833
  self._projects_view = ProjectsView(self._porringer, self._config, self)
@@ -867,13 +862,13 @@ class Screen:
867
862
  def __init__(
868
863
  self,
869
864
  porringer: API | None = None,
870
- config: GlobalConfiguration | None = None,
865
+ config: ResolvedConfig | None = None,
871
866
  ) -> None:
872
867
  """Initialize the screen.
873
868
 
874
869
  Args:
875
870
  porringer: Optional porringer API instance.
876
- config: Resolved global configuration.
871
+ config: Resolved configuration.
877
872
  """
878
873
  self._porringer = porringer
879
874
  self._config = config
@@ -10,7 +10,7 @@ import sys
10
10
  from collections.abc import Iterator
11
11
  from contextlib import contextmanager
12
12
 
13
- from PySide6.QtCore import QUrl, Signal
13
+ from PySide6.QtCore import Qt, QUrl, Signal
14
14
  from PySide6.QtGui import QDesktopServices
15
15
  from PySide6.QtWidgets import (
16
16
  QCheckBox,
@@ -30,12 +30,10 @@ from PySide6.QtWidgets import (
30
30
  from synodic_client.application.icon import app_icon
31
31
  from synodic_client.application.screen.card import CardFrame
32
32
  from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
33
- from synodic_client.config import GlobalConfiguration, save_config
34
33
  from synodic_client.logging import log_path
34
+ from synodic_client.resolution import ResolvedConfig, update_user_config
35
35
  from synodic_client.startup import is_startup_registered, register_startup, remove_startup
36
36
  from synodic_client.updater import (
37
- DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
38
- DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
39
37
  GITHUB_REPO_URL,
40
38
  )
41
39
 
@@ -45,25 +43,26 @@ logger = logging.getLogger(__name__)
45
43
  class SettingsWindow(QMainWindow):
46
44
  """Application settings window with grouped card sections.
47
45
 
48
- All controls persist changes immediately via :func:`save_config` and
49
- emit :attr:`settings_changed` so that the tray and updater can react.
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`.
50
49
  """
51
50
 
52
- settings_changed = Signal()
53
- """Emitted whenever a setting is changed and persisted."""
51
+ settings_changed = Signal(object)
52
+ """Emitted with the new ``ResolvedConfig`` whenever a setting is changed and persisted."""
54
53
 
55
54
  check_updates_requested = Signal()
56
55
  """Emitted when the user clicks the *Check for Updates* button."""
57
56
 
58
57
  def __init__(
59
58
  self,
60
- config: GlobalConfiguration,
59
+ config: ResolvedConfig,
61
60
  parent: QWidget | None = None,
62
61
  ) -> None:
63
62
  """Initialise the settings window.
64
63
 
65
64
  Args:
66
- config: The shared global configuration object.
65
+ config: The current resolved configuration snapshot.
67
66
  parent: Optional parent widget.
68
67
  """
69
68
  super().__init__(parent)
@@ -127,6 +126,12 @@ class SettingsWindow(QMainWindow):
127
126
  row.addWidget(browse_btn)
128
127
  content.addLayout(row)
129
128
 
129
+ self._add_update_controls(content)
130
+
131
+ return card
132
+
133
+ def _add_update_controls(self, content: QVBoxLayout) -> None:
134
+ """Add interval spinners, detect-updates checkbox, and update button."""
130
135
  # Auto-update interval
131
136
  row = QHBoxLayout()
132
137
  label = QLabel('App update interval (min)')
@@ -164,12 +169,11 @@ class SettingsWindow(QMainWindow):
164
169
  self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
165
170
  row.addWidget(self._check_updates_btn)
166
171
  self._update_status_label = QLabel('')
172
+ self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
167
173
  row.addWidget(self._update_status_label)
168
174
  row.addStretch()
169
175
  content.addLayout(row)
170
176
 
171
- return card
172
-
173
177
  def _build_startup_section(self) -> CardFrame:
174
178
  """Construct the *Startup* settings card."""
175
179
  card = CardFrame('Startup')
@@ -208,15 +212,9 @@ class SettingsWindow(QMainWindow):
208
212
  # Update source
209
213
  self._source_edit.setText(config.update_source or '')
210
214
 
211
- # Intervals
212
- auto_interval = config.auto_update_interval_minutes
213
- self._auto_update_spin.setValue(
214
- auto_interval if auto_interval is not None else DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
215
- )
216
- tool_interval = config.tool_update_interval_minutes
217
- self._tool_update_spin.setValue(
218
- tool_interval if tool_interval is not None else DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
219
- )
215
+ # Intervals (already resolved to concrete ints)
216
+ self._auto_update_spin.setValue(config.auto_update_interval_minutes)
217
+ self._tool_update_spin.setValue(config.tool_update_interval_minutes)
220
218
 
221
219
  # Checkboxes
222
220
  self._detect_updates_check.setChecked(config.detect_updates)
@@ -241,10 +239,14 @@ class SettingsWindow(QMainWindow):
241
239
  # Callbacks
242
240
  # ------------------------------------------------------------------
243
241
 
244
- def _persist(self) -> None:
245
- """Save config and notify listeners."""
246
- save_config(self._config)
247
- self.settings_changed.emit()
242
+ def _persist(self, **changes: object) -> None:
243
+ """Save config changes and notify listeners.
244
+
245
+ Args:
246
+ **changes: Field-name / value pairs to persist.
247
+ """
248
+ self._config = update_user_config(**changes)
249
+ self.settings_changed.emit(self._config)
248
250
 
249
251
  @contextmanager
250
252
  def _block_signals(self) -> Iterator[None]:
@@ -273,13 +275,11 @@ class SettingsWindow(QMainWindow):
273
275
  self.check_updates_requested.emit()
274
276
 
275
277
  def _on_channel_changed(self, index: int) -> None:
276
- self._config.update_channel = 'dev' if index == 1 else 'stable'
277
- self._persist()
278
+ self._persist(update_channel='dev' if index == 1 else 'stable')
278
279
 
279
280
  def _on_source_changed(self) -> None:
280
281
  text = self._source_edit.text().strip()
281
- self._config.update_source = text or None
282
- self._persist()
282
+ self._persist(update_source=text or None)
283
283
 
284
284
  def _on_browse_source(self) -> None:
285
285
  path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory')
@@ -288,25 +288,21 @@ class SettingsWindow(QMainWindow):
288
288
  self._on_source_changed()
289
289
 
290
290
  def _on_auto_update_interval_changed(self, value: int) -> None:
291
- self._config.auto_update_interval_minutes = value
292
- self._persist()
291
+ self._persist(auto_update_interval_minutes=value)
293
292
 
294
293
  def _on_tool_update_interval_changed(self, value: int) -> None:
295
- self._config.tool_update_interval_minutes = value
296
- self._persist()
294
+ self._persist(tool_update_interval_minutes=value)
297
295
 
298
296
  def _on_detect_updates_changed(self, checked: bool) -> None:
299
- self._config.detect_updates = checked
300
- self._persist()
297
+ self._persist(detect_updates=checked)
301
298
 
302
299
  def _on_auto_start_changed(self, checked: bool) -> None:
303
- self._config.auto_start = checked
304
- save_config(self._config)
300
+ self._config = update_user_config(auto_start=checked)
305
301
  if checked:
306
302
  register_startup(sys.executable)
307
303
  else:
308
304
  remove_startup()
309
- self.settings_changed.emit()
305
+ self.settings_changed.emit(self._config)
310
306
 
311
307
  @staticmethod
312
308
  def _open_log() -> None:
@@ -20,12 +20,11 @@ from synodic_client.application.screen.screen import MainWindow
20
20
  from synodic_client.application.screen.settings import SettingsWindow
21
21
  from synodic_client.application.workers import ToolUpdateWorker, UpdateCheckWorker, UpdateDownloadWorker
22
22
  from synodic_client.client import Client
23
- from synodic_client.config import GlobalConfiguration
24
23
  from synodic_client.resolution import (
24
+ ResolvedConfig,
25
25
  resolve_config,
26
26
  resolve_enabled_plugins,
27
27
  resolve_update_config,
28
- update_and_resolve,
29
28
  )
30
29
  from synodic_client.updater import UpdateInfo
31
30
 
@@ -40,7 +39,7 @@ class TrayScreen:
40
39
  app: QApplication,
41
40
  client: Client,
42
41
  window: MainWindow,
43
- config: GlobalConfiguration | None = None,
42
+ config: ResolvedConfig | None = None,
44
43
  ) -> None:
45
44
  """Initialize the tray icon.
46
45
 
@@ -123,14 +122,14 @@ class TrayScreen:
123
122
 
124
123
  # -- Config helpers --
125
124
 
126
- def _resolve_config(self) -> GlobalConfiguration:
125
+ def _resolve_config(self) -> ResolvedConfig:
127
126
  """Return the injected config or resolve from disk."""
128
127
  if self._config is not None:
129
128
  return self._config
130
129
  return resolve_config()
131
130
 
131
+ @staticmethod
132
132
  def _restart_timer(
133
- self,
134
133
  current: QTimer | None,
135
134
  interval_minutes: int,
136
135
  slot: Callable[[], None],
@@ -192,14 +191,21 @@ class TrayScreen:
192
191
  """Show the settings window."""
193
192
  self._settings_window.show()
194
193
 
195
- def _on_settings_changed(self) -> None:
194
+ def _on_settings_changed(self, config: ResolvedConfig) -> None:
196
195
  """React to a change made in the settings window."""
197
- config = self._resolve_config()
196
+ self._config = config
198
197
  self._reinitialize_updater(config)
199
198
 
200
- def _reinitialize_updater(self, config: GlobalConfiguration) -> None:
201
- """Re-derive update settings and restart the updater and timers."""
202
- update_cfg = update_and_resolve(config)
199
+ def _reinitialize_updater(self, config: ResolvedConfig) -> None:
200
+ """Re-derive update settings and restart the updater and timers.
201
+
202
+ The new ``Updater`` starts with the ``importlib.metadata``
203
+ version which may be stale after a Velopack update. The
204
+ authoritative Velopack version is recovered automatically on
205
+ the first ``_get_velopack_manager()`` call (i.e. the next
206
+ update check), so no special handling is required here.
207
+ """
208
+ update_cfg = resolve_update_config(config)
203
209
  self._client.initialize_updater(update_cfg)
204
210
  self._restart_auto_update_timer()
205
211
  self._restart_tool_update_timer()