synodic-client 0.0.1.dev30__tar.gz → 0.0.1.dev31__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 (56) hide show
  1. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/PKG-INFO +2 -2
  2. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/pyproject.toml +2 -2
  3. synodic_client-0.0.1.dev31/synodic_client/_version.py +1 -0
  4. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/qt.py +1 -2
  5. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/screen.py +11 -0
  6. synodic_client-0.0.1.dev31/synodic_client/application/screen/settings.py +289 -0
  7. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/tray.py +33 -129
  8. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/theme.py +12 -1
  9. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/resolution.py +5 -1
  10. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/updater.py +40 -2
  11. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/test_logging.py +8 -8
  12. synodic_client-0.0.1.dev31/tests/unit/qt/test_settings.py +308 -0
  13. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_resolution.py +14 -7
  14. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_updater.py +36 -0
  15. synodic_client-0.0.1.dev30/synodic_client/_version.py +0 -1
  16. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/LICENSE.md +0 -0
  17. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/README.md +0 -0
  18. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/__init__.py +0 -0
  19. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/__main__.py +0 -0
  20. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/__init__.py +0 -0
  21. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/bootstrap.py +0 -0
  22. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/icon.py +0 -0
  23. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/instance.py +0 -0
  24. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/__init__.py +0 -0
  25. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/action_card.py +0 -0
  26. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/card.py +0 -0
  27. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/install.py +0 -0
  28. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/log_panel.py +0 -0
  29. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/screen/spinner.py +0 -0
  30. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/application/uri.py +0 -0
  31. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/cli.py +0 -0
  32. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/client.py +0 -0
  33. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/config.py +0 -0
  34. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/logging.py +0 -0
  35. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/protocol.py +0 -0
  36. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/py.typed +0 -0
  37. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/synodic_client/startup.py +0 -0
  38. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/__init__.py +0 -0
  39. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/conftest.py +0 -0
  40. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/__init__.py +0 -0
  41. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/__init__.py +0 -0
  42. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/conftest.py +0 -0
  43. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/test_action_card.py +0 -0
  44. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/test_install_preview.py +0 -0
  45. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/qt/test_log_panel.py +0 -0
  46. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_cli.py +0 -0
  47. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_client_updater.py +0 -0
  48. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_client_version.py +0 -0
  49. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_config.py +0 -0
  50. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_examples.py +0 -0
  51. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_install.py +0 -0
  52. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/test_uri.py +0 -0
  53. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/windows/__init__.py +0 -0
  54. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/windows/conftest.py +0 -0
  55. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/tests/unit/windows/test_protocol.py +0 -0
  56. {synodic_client-0.0.1.dev30 → synodic_client-0.0.1.dev31}/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.dev30
3
+ Version: 0.0.1.dev31
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.dev49
11
+ Requires-Dist: porringer>=0.2.1.dev50
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.dev49",
13
+ "porringer>=0.2.1.dev50",
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.dev30"
18
+ version = "0.0.1.dev31"
19
19
 
20
20
  [project.license]
21
21
  text = "LGPL-3.0-or-later"
@@ -0,0 +1 @@
1
+ __version__ = '0.0.1.dev31'
@@ -47,8 +47,7 @@ def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfigura
47
47
  cached_dirs = porringer.cache.list_directories()
48
48
 
49
49
  logger.info(
50
- 'Synodic Client v%s started (channel: %s, source: %s, '
51
- 'config_fields_set: %s, cached_projects: %d)',
50
+ 'Synodic Client v%s started (channel: %s, source: %s, config_fields_set: %s, cached_projects: %d)',
52
51
  client.version,
53
52
  update_config.channel.name,
54
53
  update_config.repo_url,
@@ -51,6 +51,7 @@ from synodic_client.application.theme import (
51
51
  PLUGIN_SECTION_SPACING,
52
52
  PLUGIN_TOGGLE_STYLE,
53
53
  PLUGIN_UPDATE_STYLE,
54
+ SETTINGS_GEAR_STYLE,
54
55
  )
55
56
  from synodic_client.config import GlobalConfiguration, save_config
56
57
 
@@ -794,6 +795,9 @@ class ProjectsView(QWidget):
794
795
  class MainWindow(QMainWindow):
795
796
  """Main window for the application."""
796
797
 
798
+ settings_requested = Signal()
799
+ """Emitted when the user clicks the settings gear button."""
800
+
797
801
  _tabs: QTabWidget | None = None
798
802
  _plugins_view: PluginsView | None = None
799
803
  _projects_view: ProjectsView | None = None
@@ -837,6 +841,13 @@ class MainWindow(QMainWindow):
837
841
  self._plugins_view = PluginsView(self._porringer, self._config, self)
838
842
  self._tabs.addTab(self._plugins_view, 'Plugins')
839
843
 
844
+ gear_btn = QPushButton('\u2699')
845
+ gear_btn.setStyleSheet(SETTINGS_GEAR_STYLE)
846
+ gear_btn.setToolTip('Settings')
847
+ gear_btn.setFlat(True)
848
+ gear_btn.clicked.connect(self.settings_requested.emit)
849
+ self._tabs.setCornerWidget(gear_btn)
850
+
840
851
  self.setCentralWidget(self._tabs)
841
852
 
842
853
  # Paint the window immediately, then refresh data asynchronously
@@ -0,0 +1,289 @@
1
+ """Settings window for the Synodic Client application.
2
+
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.
6
+ """
7
+
8
+ import logging
9
+ import sys
10
+ from collections.abc import Iterator
11
+ from contextlib import contextmanager
12
+
13
+ from PySide6.QtCore import QUrl, Signal
14
+ from PySide6.QtGui import QDesktopServices
15
+ from PySide6.QtWidgets import (
16
+ QCheckBox,
17
+ QComboBox,
18
+ QFileDialog,
19
+ QHBoxLayout,
20
+ QLabel,
21
+ QLineEdit,
22
+ QMainWindow,
23
+ QPushButton,
24
+ QScrollArea,
25
+ QSpinBox,
26
+ QVBoxLayout,
27
+ QWidget,
28
+ )
29
+
30
+ from synodic_client.application.icon import app_icon
31
+ from synodic_client.application.screen.card import CardFrame
32
+ from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE
33
+ from synodic_client.config import GlobalConfiguration, save_config
34
+ from synodic_client.logging import log_path
35
+ from synodic_client.startup import is_startup_registered, register_startup, remove_startup
36
+ from synodic_client.updater import (
37
+ DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES,
38
+ DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES,
39
+ GITHUB_REPO_URL,
40
+ )
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ class SettingsWindow(QMainWindow):
46
+ """Application settings window with grouped card sections.
47
+
48
+ All controls persist changes immediately via :func:`save_config` and
49
+ emit :attr:`settings_changed` so that the tray and updater can react.
50
+ """
51
+
52
+ settings_changed = Signal()
53
+ """Emitted whenever a setting is changed and persisted."""
54
+
55
+ def __init__(
56
+ self,
57
+ config: GlobalConfiguration,
58
+ parent: QWidget | None = None,
59
+ ) -> None:
60
+ """Initialise the settings window.
61
+
62
+ Args:
63
+ config: The shared global configuration object.
64
+ parent: Optional parent widget.
65
+ """
66
+ super().__init__(parent)
67
+ self._config = config
68
+ self.setWindowTitle('Synodic Settings')
69
+ self.setMinimumSize(*SETTINGS_WINDOW_MIN_SIZE)
70
+ self.setWindowIcon(app_icon())
71
+ self._init_ui()
72
+
73
+ # ------------------------------------------------------------------
74
+ # UI construction
75
+ # ------------------------------------------------------------------
76
+
77
+ def _init_ui(self) -> None:
78
+ """Build the scrollable settings layout."""
79
+ scroll = QScrollArea()
80
+ scroll.setWidgetResizable(True)
81
+ scroll.setFrameShape(QScrollArea.Shape.NoFrame)
82
+
83
+ container = QWidget()
84
+ layout = QVBoxLayout(container)
85
+ layout.setContentsMargins(16, 16, 16, 16)
86
+ layout.setSpacing(12)
87
+
88
+ layout.addWidget(self._build_updates_section())
89
+ layout.addWidget(self._build_startup_section())
90
+ layout.addWidget(self._build_advanced_section())
91
+ layout.addStretch()
92
+
93
+ scroll.setWidget(container)
94
+ self.setCentralWidget(scroll)
95
+
96
+ def _build_updates_section(self) -> CardFrame:
97
+ """Construct the *Updates* settings card."""
98
+ card = CardFrame('Updates')
99
+ content = card.content_layout
100
+
101
+ # Channel
102
+ row = QHBoxLayout()
103
+ label = QLabel('Channel')
104
+ label.setMinimumWidth(160)
105
+ row.addWidget(label)
106
+ self._channel_combo = QComboBox()
107
+ self._channel_combo.addItems(['Stable', 'Development'])
108
+ self._channel_combo.currentIndexChanged.connect(self._on_channel_changed)
109
+ row.addWidget(self._channel_combo)
110
+ row.addStretch()
111
+ content.addLayout(row)
112
+
113
+ # Update Source
114
+ row = QHBoxLayout()
115
+ label = QLabel('Update source')
116
+ label.setMinimumWidth(160)
117
+ row.addWidget(label)
118
+ self._source_edit = QLineEdit()
119
+ self._source_edit.setPlaceholderText(GITHUB_REPO_URL)
120
+ self._source_edit.editingFinished.connect(self._on_source_changed)
121
+ row.addWidget(self._source_edit, 1)
122
+ browse_btn = QPushButton('Browse\u2026')
123
+ browse_btn.clicked.connect(self._on_browse_source)
124
+ row.addWidget(browse_btn)
125
+ content.addLayout(row)
126
+
127
+ # Auto-update interval
128
+ row = QHBoxLayout()
129
+ label = QLabel('App update interval (min)')
130
+ label.setMinimumWidth(160)
131
+ row.addWidget(label)
132
+ self._auto_update_spin = QSpinBox()
133
+ self._auto_update_spin.setRange(0, 1440)
134
+ self._auto_update_spin.setSpecialValueText('Disabled')
135
+ self._auto_update_spin.valueChanged.connect(self._on_auto_update_interval_changed)
136
+ row.addWidget(self._auto_update_spin)
137
+ row.addStretch()
138
+ content.addLayout(row)
139
+
140
+ # Tool-update interval
141
+ row = QHBoxLayout()
142
+ label = QLabel('Tool update interval (min)')
143
+ label.setMinimumWidth(160)
144
+ row.addWidget(label)
145
+ self._tool_update_spin = QSpinBox()
146
+ self._tool_update_spin.setRange(0, 1440)
147
+ self._tool_update_spin.setSpecialValueText('Disabled')
148
+ self._tool_update_spin.valueChanged.connect(self._on_tool_update_interval_changed)
149
+ row.addWidget(self._tool_update_spin)
150
+ row.addStretch()
151
+ content.addLayout(row)
152
+
153
+ # Detect updates during previews
154
+ self._detect_updates_check = QCheckBox('Detect updates during previews')
155
+ self._detect_updates_check.toggled.connect(self._on_detect_updates_changed)
156
+ content.addWidget(self._detect_updates_check)
157
+
158
+ return card
159
+
160
+ def _build_startup_section(self) -> CardFrame:
161
+ """Construct the *Startup* settings card."""
162
+ card = CardFrame('Startup')
163
+ self._auto_start_check = QCheckBox('Start with Windows')
164
+ self._auto_start_check.toggled.connect(self._on_auto_start_changed)
165
+ card.content_layout.addWidget(self._auto_start_check)
166
+ return card
167
+
168
+ def _build_advanced_section(self) -> CardFrame:
169
+ """Construct the *Advanced* settings card."""
170
+ card = CardFrame('Advanced')
171
+ row = QHBoxLayout()
172
+ open_log_btn = QPushButton('Open Log\u2026')
173
+ open_log_btn.clicked.connect(self._open_log)
174
+ row.addWidget(open_log_btn)
175
+ row.addStretch()
176
+ card.content_layout.addLayout(row)
177
+ return card
178
+
179
+ # ------------------------------------------------------------------
180
+ # Public API
181
+ # ------------------------------------------------------------------
182
+
183
+ def sync_from_config(self) -> None:
184
+ """Synchronize all controls from the current configuration.
185
+
186
+ Signals are blocked during the update to prevent feedback loops.
187
+ """
188
+ config = self._config
189
+
190
+ with self._block_signals():
191
+ # Channel: index 0 = Stable, 1 = Development
192
+ is_dev = config.update_channel == 'dev'
193
+ self._channel_combo.setCurrentIndex(1 if is_dev else 0)
194
+
195
+ # Update source
196
+ self._source_edit.setText(config.update_source or '')
197
+
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
+ )
207
+
208
+ # Checkboxes
209
+ self._detect_updates_check.setChecked(config.detect_updates)
210
+ self._auto_start_check.setChecked(is_startup_registered())
211
+
212
+ def show(self) -> None:
213
+ """Sync controls from config, then show the window."""
214
+ self.sync_from_config()
215
+ super().show()
216
+ self.raise_()
217
+ self.activateWindow()
218
+
219
+ # ------------------------------------------------------------------
220
+ # Callbacks
221
+ # ------------------------------------------------------------------
222
+
223
+ def _persist(self) -> None:
224
+ """Save config and notify listeners."""
225
+ save_config(self._config)
226
+ self.settings_changed.emit()
227
+
228
+ @contextmanager
229
+ def _block_signals(self) -> Iterator[None]:
230
+ """Temporarily block signals on all settings controls."""
231
+ widgets = (
232
+ self._channel_combo,
233
+ self._source_edit,
234
+ self._auto_update_spin,
235
+ self._tool_update_spin,
236
+ self._detect_updates_check,
237
+ self._auto_start_check,
238
+ )
239
+ for w in widgets:
240
+ w.blockSignals(True)
241
+ try:
242
+ yield
243
+ finally:
244
+ for w in widgets:
245
+ w.blockSignals(False)
246
+
247
+ def _on_channel_changed(self, index: int) -> None:
248
+ self._config.update_channel = 'dev' if index == 1 else 'stable'
249
+ self._persist()
250
+
251
+ def _on_source_changed(self) -> None:
252
+ text = self._source_edit.text().strip()
253
+ self._config.update_source = text or None
254
+ self._persist()
255
+
256
+ def _on_browse_source(self) -> None:
257
+ path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory')
258
+ if path:
259
+ self._source_edit.setText(path)
260
+ self._on_source_changed()
261
+
262
+ def _on_auto_update_interval_changed(self, value: int) -> None:
263
+ self._config.auto_update_interval_minutes = value
264
+ self._persist()
265
+
266
+ def _on_tool_update_interval_changed(self, value: int) -> None:
267
+ self._config.tool_update_interval_minutes = value
268
+ self._persist()
269
+
270
+ def _on_detect_updates_changed(self, checked: bool) -> None:
271
+ self._config.detect_updates = checked
272
+ self._persist()
273
+
274
+ def _on_auto_start_changed(self, checked: bool) -> None:
275
+ self._config.auto_start = checked
276
+ save_config(self._config)
277
+ if checked:
278
+ register_startup(sys.executable)
279
+ else:
280
+ remove_startup()
281
+ self.settings_changed.emit()
282
+
283
+ @staticmethod
284
+ def _open_log() -> None:
285
+ """Open the log file in the system's default editor."""
286
+ path = log_path()
287
+ if not path.exists():
288
+ path.touch()
289
+ QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
@@ -2,43 +2,32 @@
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import sys
6
5
  from pathlib import Path
7
6
 
8
7
  from porringer.api import API
9
8
  from porringer.schema import SetupParameters, SyncStrategy
10
- from PySide6.QtCore import QThread, QTimer, QUrl, Signal
11
- from PySide6.QtGui import QAction, QDesktopServices
9
+ from PySide6.QtCore import QThread, QTimer, Signal
10
+ from PySide6.QtGui import QAction
12
11
  from PySide6.QtWidgets import (
13
12
  QApplication,
14
- QDialog,
15
- QFileDialog,
16
- QHBoxLayout,
17
- QLabel,
18
- QLineEdit,
19
13
  QMenu,
20
14
  QMessageBox,
21
15
  QProgressDialog,
22
- QPushButton,
23
16
  QSystemTrayIcon,
24
- QVBoxLayout,
25
- QWidget,
26
17
  )
27
18
 
28
19
  from synodic_client.application.icon import app_icon
29
20
  from synodic_client.application.screen.screen import MainWindow
30
- from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH
21
+ from synodic_client.application.screen.settings import SettingsWindow
31
22
  from synodic_client.client import Client
32
- from synodic_client.config import GlobalConfiguration, save_config
33
- from synodic_client.logging import log_path
23
+ from synodic_client.config import GlobalConfiguration
34
24
  from synodic_client.resolution import (
35
25
  resolve_config,
36
26
  resolve_enabled_plugins,
37
27
  resolve_update_config,
38
28
  update_and_resolve,
39
29
  )
40
- from synodic_client.startup import is_startup_registered, register_startup, remove_startup
41
- from synodic_client.updater import GITHUB_REPO_URL, UpdateChannel, UpdateInfo
30
+ from synodic_client.updater import UpdateChannel, UpdateInfo
42
31
 
43
32
  logger = logging.getLogger(__name__)
44
33
 
@@ -138,60 +127,6 @@ class ToolUpdateWorker(QThread):
138
127
  pass # consume events to completion
139
128
 
140
129
 
141
- class UpdateSourceDialog(QDialog):
142
- """Dialog for editing the Velopack update source URL or local path."""
143
-
144
- def __init__(self, current_source: str | None, parent: QWidget | None = None) -> None:
145
- """Initialise the dialog.
146
-
147
- Args:
148
- current_source: The current update source value (may be ``None``).
149
- parent: Optional parent widget.
150
- """
151
- super().__init__(parent)
152
- self.setWindowTitle('Update Source')
153
- self.setMinimumWidth(UPDATE_SOURCE_DIALOG_MIN_WIDTH)
154
-
155
- layout = QVBoxLayout(self)
156
-
157
- label = QLabel(
158
- 'Enter a URL or local path for Velopack releases.\nLeave blank to use the default GitHub source.',
159
- )
160
- layout.addWidget(label)
161
-
162
- self._source_edit = QLineEdit(current_source or '')
163
- self._source_edit.setPlaceholderText(GITHUB_REPO_URL)
164
-
165
- browse_button = QPushButton('Browse...')
166
- browse_button.clicked.connect(self._browse)
167
-
168
- row = QHBoxLayout()
169
- row.addWidget(self._source_edit)
170
- row.addWidget(browse_button)
171
- layout.addLayout(row)
172
-
173
- button_row = QHBoxLayout()
174
- ok_button = QPushButton('OK')
175
- cancel_button = QPushButton('Cancel')
176
- button_row.addStretch()
177
- button_row.addWidget(ok_button)
178
- button_row.addWidget(cancel_button)
179
- layout.addLayout(button_row)
180
-
181
- ok_button.clicked.connect(self.accept)
182
- cancel_button.clicked.connect(self.reject)
183
-
184
- def _browse(self) -> None:
185
- path = QFileDialog.getExistingDirectory(self, 'Select Releases Directory')
186
- if path:
187
- self._source_edit.setText(path)
188
-
189
- @property
190
- def source(self) -> str | None:
191
- """Return the trimmed source text, or ``None`` if blank."""
192
- return self._source_edit.text().strip() or None
193
-
194
-
195
130
  class TrayScreen:
196
131
  """Tray screen for the application."""
197
132
 
@@ -231,6 +166,13 @@ class TrayScreen:
231
166
 
232
167
  self._build_menu(app, window)
233
168
 
169
+ # Settings window (created once, shown/hidden on demand)
170
+ self._settings_window = SettingsWindow(self._resolve_config())
171
+ self._settings_window.settings_changed.connect(self._on_settings_changed)
172
+
173
+ # MainWindow gear button → open settings
174
+ window.settings_requested.connect(self._show_settings)
175
+
234
176
  # Periodic auto-update checking
235
177
  self._auto_update_timer: QTimer | None = None
236
178
  self._start_auto_update_timer()
@@ -253,24 +195,15 @@ class TrayScreen:
253
195
  self.menu.addAction(self.open_action)
254
196
  self.open_action.triggered.connect(window.show)
255
197
 
256
- # Settings submenu
257
- self.settings_menu = QMenu('Settings', self.menu)
258
- self.menu.addMenu(self.settings_menu)
198
+ self.menu.addSeparator()
259
199
 
260
- self.update_action = QAction('Check for Updates...', self.settings_menu)
200
+ self.update_action = QAction('Check for Updates...', self.menu)
261
201
  self.update_action.triggered.connect(self._on_check_updates)
262
- self.settings_menu.addAction(self.update_action)
263
-
264
- self.settings_menu.addSeparator()
265
-
266
- # Update Source action
267
- self.update_source_action = QAction('Update Source...', self.settings_menu)
268
- self.update_source_action.triggered.connect(self._on_update_source)
269
- self.settings_menu.addAction(self.update_source_action)
202
+ self.menu.addAction(self.update_action)
270
203
 
271
204
  # Update Channel submenu
272
- self.channel_menu = QMenu('Update Channel', self.settings_menu)
273
- self.settings_menu.addMenu(self.channel_menu)
205
+ self.channel_menu = QMenu('Update Channel', self.menu)
206
+ self.menu.addMenu(self.channel_menu)
274
207
 
275
208
  self._channel_stable_action = QAction('Stable', self.channel_menu)
276
209
  self._channel_stable_action.setCheckable(True)
@@ -285,20 +218,11 @@ class TrayScreen:
285
218
  # Set initial channel check state from config
286
219
  self._sync_channel_checks()
287
220
 
288
- self.settings_menu.addSeparator()
289
-
290
- # Start with Windows toggle
291
- self._auto_start_action = QAction('Start with Windows', self.settings_menu)
292
- self._auto_start_action.setCheckable(True)
293
- self._auto_start_action.setChecked(is_startup_registered())
294
- self._auto_start_action.triggered.connect(self._on_auto_start_toggled)
295
- self.settings_menu.addAction(self._auto_start_action)
296
-
297
- self.settings_menu.addSeparator()
221
+ self.menu.addSeparator()
298
222
 
299
- self.open_log_action = QAction('Open Log...', self.settings_menu)
300
- self.open_log_action.triggered.connect(self._open_log)
301
- self.settings_menu.addAction(self.open_log_action)
223
+ self.settings_action = QAction('Settings\u2026', self.menu)
224
+ self.settings_action.triggered.connect(self._show_settings)
225
+ self.menu.addAction(self.settings_action)
302
226
 
303
227
  self.menu.addSeparator()
304
228
 
@@ -310,14 +234,6 @@ class TrayScreen:
310
234
 
311
235
  # -- Config helpers --
312
236
 
313
- @staticmethod
314
- def _open_log() -> None:
315
- """Open the log file in the system's default editor."""
316
- path = log_path()
317
- if not path.exists():
318
- path.touch()
319
- QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
320
-
321
237
  def _resolve_config(self) -> GlobalConfiguration:
322
238
  """Return the injected config or resolve from disk."""
323
239
  if self._config is not None:
@@ -376,38 +292,26 @@ class TrayScreen:
376
292
  self._window.raise_()
377
293
  self._window.activateWindow()
378
294
 
379
- def _on_update_source(self) -> None:
380
- """Open a dialog to edit the update source URL or local path."""
381
- config = self._resolve_config()
382
-
383
- parent = self._window if self._window.isVisible() else None
384
- dialog = UpdateSourceDialog(config.update_source, parent)
295
+ def _show_settings(self) -> None:
296
+ """Show the settings window."""
297
+ self._settings_window.show()
385
298
 
386
- if dialog.exec() == QDialog.DialogCode.Accepted:
387
- config.update_source = dialog.source
388
- logger.info('Update source changed to: %s', dialog.source or '(default)')
389
- self._reinitialize_updater(config)
299
+ def _on_settings_changed(self) -> None:
300
+ """React to a change made in the settings window."""
301
+ config = self._resolve_config()
302
+ self._reinitialize_updater(config)
303
+ self._sync_channel_checks()
390
304
 
391
305
  def _on_channel_changed(self, channel: UpdateChannel) -> None:
392
- """Handle channel selection change."""
306
+ """Handle channel selection change from the tray submenu."""
393
307
  config = self._resolve_config()
394
308
  config.update_channel = 'dev' if channel == UpdateChannel.DEVELOPMENT else 'stable'
395
309
  logger.info('Update channel changed to: %s', config.update_channel)
396
310
  self._sync_channel_checks()
397
311
  self._reinitialize_updater(config)
398
-
399
- def _on_auto_start_toggled(self, checked: bool) -> None:
400
- """Handle Start with Windows toggle."""
401
- config = self._resolve_config()
402
- config.auto_start = checked
403
- save_config(config)
404
-
405
- if checked:
406
- register_startup(sys.executable)
407
- else:
408
- remove_startup()
409
-
410
- logger.info('Auto-startup %s', 'enabled' if checked else 'disabled')
312
+ # Keep the settings window in sync if it is visible
313
+ if self._settings_window.isVisible():
314
+ self._settings_window.sync_from_config()
411
315
 
412
316
  def _reinitialize_updater(self, config: GlobalConfiguration) -> None:
413
317
  """Re-derive update settings and restart the updater and timers."""
@@ -9,7 +9,6 @@ code focused on layout and behaviour rather than pixel tweaking.
9
9
  # ---------------------------------------------------------------------------
10
10
  INSTALL_PREVIEW_MIN_SIZE = (650, 400)
11
11
  MAIN_WINDOW_MIN_SIZE = (600, 400)
12
- UPDATE_SOURCE_DIALOG_MIN_WIDTH = 450
13
12
 
14
13
  # ---------------------------------------------------------------------------
15
14
  # Layout margins (left, top, right, bottom)
@@ -275,3 +274,15 @@ METADATA_SKELETON_STYLE = (
275
274
  '}'
276
275
  )
277
276
  """Muted card frame used as the metadata placeholder during loading."""
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Settings window
280
+ # ---------------------------------------------------------------------------
281
+ SETTINGS_WINDOW_MIN_SIZE = (500, 450)
282
+ """Minimum size (width, height) for the Settings window."""
283
+
284
+ SETTINGS_GEAR_STYLE = (
285
+ 'QPushButton { border: none; font-size: 16px; padding: 2px 6px; }'
286
+ 'QPushButton:hover { background: palette(midlight); border-radius: 3px; }'
287
+ )
288
+ """Gear button style for the MainWindow tab corner widget."""
@@ -21,6 +21,7 @@ from synodic_client.updater import (
21
21
  GITHUB_REPO_URL,
22
22
  UpdateChannel,
23
23
  UpdateConfig,
24
+ github_release_asset_url,
24
25
  )
25
26
 
26
27
  logger = logging.getLogger(__name__)
@@ -84,7 +85,10 @@ def resolve_update_config(config: GlobalConfiguration) -> UpdateConfig:
84
85
  else:
85
86
  channel = UpdateChannel.DEVELOPMENT if is_dev else UpdateChannel.STABLE
86
87
 
87
- repo_url = config.update_source or GITHUB_REPO_URL
88
+ repo_url = github_release_asset_url(
89
+ config.update_source or GITHUB_REPO_URL,
90
+ channel,
91
+ )
88
92
 
89
93
  interval = config.auto_update_interval_minutes
90
94
  if interval is None: