synodic-client 0.0.1.dev32__tar.gz → 0.0.1.dev34__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.dev32 → synodic_client-0.0.1.dev34}/PKG-INFO +1 -1
  2. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/pyproject.toml +1 -1
  3. synodic_client-0.0.1.dev34/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/bootstrap.py +5 -2
  5. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/qt.py +15 -7
  6. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/install.py +13 -10
  7. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/screen.py +14 -19
  8. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/settings.py +63 -39
  9. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/tray.py +82 -179
  10. synodic_client-0.0.1.dev34/synodic_client/application/workers.py +112 -0
  11. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/config.py +67 -60
  12. synodic_client-0.0.1.dev34/synodic_client/resolution.py +265 -0
  13. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/updater.py +18 -2
  14. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/test_settings.py +94 -31
  15. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_config.py +45 -52
  16. synodic_client-0.0.1.dev34/tests/unit/test_resolution.py +407 -0
  17. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_updater.py +43 -20
  18. synodic_client-0.0.1.dev32/synodic_client/_version.py +0 -1
  19. synodic_client-0.0.1.dev32/synodic_client/resolution.py +0 -166
  20. synodic_client-0.0.1.dev32/tests/unit/test_resolution.py +0 -414
  21. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/LICENSE.md +0 -0
  22. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/README.md +0 -0
  23. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/__init__.py +0 -0
  24. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/__main__.py +0 -0
  25. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/__init__.py +0 -0
  26. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/icon.py +0 -0
  27. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/instance.py +0 -0
  28. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/__init__.py +0 -0
  29. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/action_card.py +0 -0
  30. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/card.py +0 -0
  31. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/log_panel.py +0 -0
  32. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/screen/spinner.py +0 -0
  33. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/theme.py +0 -0
  34. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/application/uri.py +0 -0
  35. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/cli.py +0 -0
  36. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/client.py +0 -0
  37. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/logging.py +0 -0
  38. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/protocol.py +0 -0
  39. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/py.typed +0 -0
  40. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/synodic_client/startup.py +0 -0
  41. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/conftest.py +0 -0
  43. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/__init__.py +0 -0
  44. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/__init__.py +0 -0
  45. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/conftest.py +0 -0
  46. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/test_action_card.py +0 -0
  47. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/test_install_preview.py +0 -0
  48. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/test_log_panel.py +0 -0
  49. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/qt/test_logging.py +0 -0
  50. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_cli.py +0 -0
  51. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_client_updater.py +0 -0
  52. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_client_version.py +0 -0
  53. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_examples.py +0 -0
  54. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_install.py +0 -0
  55. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/test_uri.py +0 -0
  56. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/windows/__init__.py +0 -0
  57. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/windows/conftest.py +0 -0
  58. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/tests/unit/windows/test_protocol.py +0 -0
  59. {synodic_client-0.0.1.dev32 → synodic_client-0.0.1.dev34}/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.dev32
3
+ Version: 0.0.1.dev34
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.1442.dev64255",
16
16
  "typer>=0.24.1",
17
17
  ]
18
- version = "0.0.1.dev32"
18
+ version = "0.0.1.dev34"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev34'
@@ -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()
@@ -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
@@ -1,8 +1,8 @@
1
1
  """Settings window for the Synodic Client application.
2
2
 
3
3
  Provides a single-page window with grouped sections for all application
4
- settings. Quick-access items (Channel, Check for Updates) remain in the
5
- tray menu; the full set is available here.
4
+ settings including update-channel selection and a manual *Check for
5
+ Updates* button with inline status feedback.
6
6
  """
7
7
 
8
8
  import logging
@@ -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,22 +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."""
53
+
54
+ check_updates_requested = Signal()
55
+ """Emitted when the user clicks the *Check for Updates* button."""
54
56
 
55
57
  def __init__(
56
58
  self,
57
- config: GlobalConfiguration,
59
+ config: ResolvedConfig,
58
60
  parent: QWidget | None = None,
59
61
  ) -> None:
60
62
  """Initialise the settings window.
61
63
 
62
64
  Args:
63
- config: The shared global configuration object.
65
+ config: The current resolved configuration snapshot.
64
66
  parent: Optional parent widget.
65
67
  """
66
68
  super().__init__(parent)
@@ -124,6 +126,12 @@ class SettingsWindow(QMainWindow):
124
126
  row.addWidget(browse_btn)
125
127
  content.addLayout(row)
126
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."""
127
135
  # Auto-update interval
128
136
  row = QHBoxLayout()
129
137
  label = QLabel('App update interval (min)')
@@ -155,7 +163,16 @@ class SettingsWindow(QMainWindow):
155
163
  self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
156
164
  content.addWidget(self._detect_updates_check)
157
165
 
158
- return card
166
+ # Check for Updates
167
+ row = QHBoxLayout()
168
+ self._check_updates_btn = QPushButton('Check for Updates\u2026')
169
+ self._check_updates_btn.clicked.connect(self._on_check_updates_clicked)
170
+ row.addWidget(self._check_updates_btn)
171
+ self._update_status_label = QLabel('')
172
+ self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
173
+ row.addWidget(self._update_status_label)
174
+ row.addStretch()
175
+ content.addLayout(row)
159
176
 
160
177
  def _build_startup_section(self) -> CardFrame:
161
178
  """Construct the *Startup* settings card."""
@@ -195,20 +212,22 @@ class SettingsWindow(QMainWindow):
195
212
  # Update source
196
213
  self._source_edit.setText(config.update_source or '')
197
214
 
198
- # Intervals
199
- auto_interval = config.auto_update_interval_minutes
200
- self._auto_update_spin.setValue(
201
- auto_interval if auto_interval is not None else DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
202
- )
203
- tool_interval = config.tool_update_interval_minutes
204
- self._tool_update_spin.setValue(
205
- tool_interval if tool_interval is not None else DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
206
- )
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)
207
218
 
208
219
  # Checkboxes
209
220
  self._detect_updates_check.setChecked(config.detect_updates)
210
221
  self._auto_start_check.setChecked(is_startup_registered())
211
222
 
223
+ def set_update_status(self, text: str) -> None:
224
+ """Set the inline status text next to the *Check for Updates* button."""
225
+ self._update_status_label.setText(text)
226
+
227
+ def reset_check_updates_button(self) -> None:
228
+ """Re-enable the *Check for Updates* button after a check completes."""
229
+ self._check_updates_btn.setEnabled(True)
230
+
212
231
  def show(self) -> None:
213
232
  """Sync controls from config, then show the window."""
214
233
  self.sync_from_config()
@@ -220,10 +239,14 @@ class SettingsWindow(QMainWindow):
220
239
  # Callbacks
221
240
  # ------------------------------------------------------------------
222
241
 
223
- def _persist(self) -> None:
224
- """Save config and notify listeners."""
225
- save_config(self._config)
226
- 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)
227
250
 
228
251
  @contextmanager
229
252
  def _block_signals(self) -> Iterator[None]:
@@ -235,6 +258,7 @@ class SettingsWindow(QMainWindow):
235
258
  self._tool_update_spin,
236
259
  self._detect_updates_check,
237
260
  self._auto_start_check,
261
+ self._check_updates_btn,
238
262
  )
239
263
  for w in widgets:
240
264
  w.blockSignals(True)
@@ -244,14 +268,18 @@ class SettingsWindow(QMainWindow):
244
268
  for w in widgets:
245
269
  w.blockSignals(False)
246
270
 
271
+ def _on_check_updates_clicked(self) -> None:
272
+ """Handle the *Check for Updates* button click."""
273
+ self._check_updates_btn.setEnabled(False)
274
+ self._update_status_label.setText('Checking\u2026')
275
+ self.check_updates_requested.emit()
276
+
247
277
  def _on_channel_changed(self, index: int) -> None:
248
- self._config.update_channel = 'dev' if index == 1 else 'stable'
249
- self._persist()
278
+ self._persist(update_channel='dev' if index == 1 else 'stable')
250
279
 
251
280
  def _on_source_changed(self) -> None:
252
281
  text = self._source_edit.text().strip()
253
- self._config.update_source = text or None
254
- self._persist()
282
+ self._persist(update_source=text or None)
255
283
 
256
284
  def _on_browse_source(self) -> None:
257
285
  path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory')
@@ -260,25 +288,21 @@ class SettingsWindow(QMainWindow):
260
288
  self._on_source_changed()
261
289
 
262
290
  def _on_auto_update_interval_changed(self, value: int) -> None:
263
- self._config.auto_update_interval_minutes = value
264
- self._persist()
291
+ self._persist(auto_update_interval_minutes=value)
265
292
 
266
293
  def _on_tool_update_interval_changed(self, value: int) -> None:
267
- self._config.tool_update_interval_minutes = value
268
- self._persist()
294
+ self._persist(tool_update_interval_minutes=value)
269
295
 
270
296
  def _on_detect_updates_changed(self, checked: bool) -> None:
271
- self._config.detect_updates = checked
272
- self._persist()
297
+ self._persist(detect_updates=checked)
273
298
 
274
299
  def _on_auto_start_changed(self, checked: bool) -> None:
275
- self._config.auto_start = checked
276
- save_config(self._config)
300
+ self._config = update_user_config(auto_start=checked)
277
301
  if checked:
278
302
  register_startup(sys.executable)
279
303
  else:
280
304
  remove_startup()
281
- self.settings_changed.emit()
305
+ self.settings_changed.emit(self._config)
282
306
 
283
307
  @staticmethod
284
308
  def _open_log() -> None: