synodic-client 0.0.1.dev42__tar.gz → 0.0.1.dev44__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.
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/PKG-INFO +1 -1
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/pyproject.toml +1 -1
- synodic_client-0.0.1.dev44/synodic_client/_version.py +1 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/bootstrap.py +3 -13
- synodic_client-0.0.1.dev44/synodic_client/application/init.py +61 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/qt.py +4 -16
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/settings.py +6 -0
- synodic_client-0.0.1.dev44/synodic_client/startup.py +156 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/updater.py +11 -3
- synodic_client-0.0.1.dev44/tests/unit/test_init.py +103 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_updater.py +16 -0
- synodic_client-0.0.1.dev44/tests/unit/windows/test_startup.py +240 -0
- synodic_client-0.0.1.dev42/synodic_client/_version.py +0 -1
- synodic_client-0.0.1.dev42/synodic_client/startup.py +0 -91
- synodic_client-0.0.1.dev42/tests/unit/windows/test_startup.py +0 -121
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/LICENSE.md +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/README.md +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/__main__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/data.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/icon.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/instance.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/action_card.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/card.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/install.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/log_panel.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/screen.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/sidebar.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/spinner.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/tray.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/update_banner.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/theme.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/update_controller.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/uri.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/workers.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/cli.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/client.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/config.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/logging.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/protocol.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/py.typed +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/resolution.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/conftest.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/conftest.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_action_card.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_gather_packages.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_install_preview.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_log_panel.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_logging.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_preview_model.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_settings.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_sidebar.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_tray_window_show.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_banner.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_controller.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_feedback.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_cli.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_client_updater.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_client_version.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_config.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_examples.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_install.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_resolution.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_uri.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/test_workers.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/windows/__init__.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/windows/conftest.py +0 -0
- {synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/windows/test_protocol.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.0.1.dev44'
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/bootstrap.py
RENAMED
|
@@ -9,7 +9,7 @@ Import order matters:
|
|
|
9
9
|
1. stdlib + config (pure-Python, fast)
|
|
10
10
|
2. configure_logging() — now Qt-free
|
|
11
11
|
3. initialize_velopack() — hooks run with logging active
|
|
12
|
-
4.
|
|
12
|
+
4. run_startup_preamble() — protocol, config seed, auto-startup
|
|
13
13
|
5. import qt.application — PySide6 / porringer loaded here
|
|
14
14
|
"""
|
|
15
15
|
|
|
@@ -17,9 +17,6 @@ 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.protocol import register_protocol
|
|
21
|
-
from synodic_client.resolution import resolve_config, seed_user_config_from_build
|
|
22
|
-
from synodic_client.startup import register_startup, remove_startup
|
|
23
20
|
from synodic_client.updater import initialize_velopack
|
|
24
21
|
|
|
25
22
|
_PROTOCOL_SCHEME = 'synodic'
|
|
@@ -32,16 +29,9 @@ configure_logging()
|
|
|
32
29
|
initialize_velopack()
|
|
33
30
|
|
|
34
31
|
if not _dev_mode:
|
|
35
|
-
|
|
36
|
-
seed_user_config_from_build()
|
|
32
|
+
from synodic_client.application.init import run_startup_preamble
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
_config = resolve_config()
|
|
41
|
-
if _config.auto_start:
|
|
42
|
-
register_startup(sys.executable)
|
|
43
|
-
else:
|
|
44
|
-
remove_startup()
|
|
34
|
+
run_startup_preamble(sys.executable)
|
|
45
35
|
|
|
46
36
|
# Heavy imports happen here — PySide6, porringer, etc.
|
|
47
37
|
from synodic_client.application.qt import application
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Shared startup preamble for frozen and CLI entry points.
|
|
2
|
+
|
|
3
|
+
Encapsulates the one-time initialisation that both
|
|
4
|
+
:mod:`synodic_client.application.bootstrap` (PyInstaller) and
|
|
5
|
+
:mod:`synodic_client.application.qt` (CLI / dev-script) need to
|
|
6
|
+
perform before the GUI event loop starts:
|
|
7
|
+
|
|
8
|
+
1. Seed user config from the build config (one-time propagation).
|
|
9
|
+
2. Register the ``synodic://`` URI protocol handler.
|
|
10
|
+
3. Synchronise the Windows auto-startup registry entry with the
|
|
11
|
+
persisted ``auto_start`` preference.
|
|
12
|
+
|
|
13
|
+
Heavy dependencies (PySide6, porringer) are **not** imported here so
|
|
14
|
+
that the bootstrap path can call this before loading them.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from synodic_client.protocol import register_protocol
|
|
21
|
+
from synodic_client.resolution import resolve_config, seed_user_config_from_build
|
|
22
|
+
from synodic_client.startup import register_startup, remove_startup
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_preamble_done = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_startup_preamble(exe_path: str | None = None) -> None:
|
|
30
|
+
"""Run the shared startup preamble for non-dev-mode launches.
|
|
31
|
+
|
|
32
|
+
Both the frozen entry point
|
|
33
|
+
(:mod:`~synodic_client.application.bootstrap`) and the CLI entry
|
|
34
|
+
point (:func:`~synodic_client.application.qt.application`) call
|
|
35
|
+
this unconditionally. An internal guard ensures the work only
|
|
36
|
+
executes once per process.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
exe_path: Absolute path to the application executable. Defaults
|
|
40
|
+
to ``sys.executable`` when not supplied.
|
|
41
|
+
"""
|
|
42
|
+
global _preamble_done # noqa: PLW0603
|
|
43
|
+
if _preamble_done:
|
|
44
|
+
return
|
|
45
|
+
_preamble_done = True
|
|
46
|
+
|
|
47
|
+
if exe_path is None:
|
|
48
|
+
exe_path = sys.executable
|
|
49
|
+
|
|
50
|
+
# Seed user config from the build config (one-time propagation).
|
|
51
|
+
seed_user_config_from_build()
|
|
52
|
+
|
|
53
|
+
register_protocol(exe_path)
|
|
54
|
+
|
|
55
|
+
config = resolve_config()
|
|
56
|
+
if config.auto_start:
|
|
57
|
+
register_startup(exe_path)
|
|
58
|
+
else:
|
|
59
|
+
remove_startup()
|
|
60
|
+
|
|
61
|
+
logger.info('Startup preamble complete (auto_start=%s)', config.auto_start)
|
|
@@ -15,6 +15,7 @@ from PySide6.QtCore import Qt, QTimer
|
|
|
15
15
|
from PySide6.QtWidgets import QApplication
|
|
16
16
|
|
|
17
17
|
from synodic_client.application.icon import app_icon
|
|
18
|
+
from synodic_client.application.init import run_startup_preamble
|
|
18
19
|
from synodic_client.application.instance import SingleInstance
|
|
19
20
|
from synodic_client.application.screen.install import InstallPreviewWindow
|
|
20
21
|
from synodic_client.application.screen.screen import Screen
|
|
@@ -23,15 +24,12 @@ from synodic_client.application.uri import parse_uri
|
|
|
23
24
|
from synodic_client.client import Client
|
|
24
25
|
from synodic_client.config import set_dev_mode
|
|
25
26
|
from synodic_client.logging import configure_logging
|
|
26
|
-
from synodic_client.protocol import register_protocol
|
|
27
27
|
from synodic_client.resolution import (
|
|
28
28
|
ResolvedConfig,
|
|
29
29
|
resolve_config,
|
|
30
30
|
resolve_update_config,
|
|
31
31
|
resolve_version,
|
|
32
|
-
seed_user_config_from_build,
|
|
33
32
|
)
|
|
34
|
-
from synodic_client.startup import register_startup, remove_startup
|
|
35
33
|
from synodic_client.updater import initialize_velopack
|
|
36
34
|
|
|
37
35
|
|
|
@@ -139,20 +137,10 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
|
|
|
139
137
|
_install_exception_hook(logger)
|
|
140
138
|
|
|
141
139
|
if not dev_mode:
|
|
142
|
-
#
|
|
143
|
-
#
|
|
144
|
-
# PyInstaller runtime hook (rthook_no_console.py).
|
|
140
|
+
# All three functions are idempotent — safe to call even when
|
|
141
|
+
# bootstrap.py has already executed them before heavy imports.
|
|
145
142
|
initialize_velopack()
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# Seed user config from build config (one-time propagation).
|
|
149
|
-
seed_user_config_from_build()
|
|
150
|
-
|
|
151
|
-
startup_config = resolve_config()
|
|
152
|
-
if startup_config.auto_start:
|
|
153
|
-
register_startup(sys.executable)
|
|
154
|
-
else:
|
|
155
|
-
remove_startup()
|
|
143
|
+
run_startup_preamble(sys.executable)
|
|
156
144
|
|
|
157
145
|
if uri:
|
|
158
146
|
logger.info('Received URI: %s', uri)
|
|
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
|
|
|
27
27
|
QWidget,
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
+
from synodic_client._version import __version__
|
|
30
31
|
from synodic_client.application.icon import app_icon
|
|
31
32
|
from synodic_client.application.screen.card import CardFrame
|
|
32
33
|
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
|
|
@@ -92,6 +93,11 @@ class SettingsWindow(QMainWindow):
|
|
|
92
93
|
layout.addWidget(self._build_advanced_section())
|
|
93
94
|
layout.addStretch()
|
|
94
95
|
|
|
96
|
+
version_label = QLabel(f'Version {__version__}')
|
|
97
|
+
version_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
98
|
+
version_label.setStyleSheet('color: rgba(255, 255, 255, 0.4); font-size: 11px;')
|
|
99
|
+
layout.addWidget(version_label)
|
|
100
|
+
|
|
95
101
|
scroll.setWidget(container)
|
|
96
102
|
self.setCentralWidget(scroll)
|
|
97
103
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
r"""Windows auto-startup registration via the registry.
|
|
2
|
+
|
|
3
|
+
Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
|
|
4
|
+
so the application launches automatically when the user logs in.
|
|
5
|
+
|
|
6
|
+
Windows also maintains a parallel
|
|
7
|
+
``HKCU\...\Explorer\StartupApproved\Run`` key where each entry is a
|
|
8
|
+
12-byte ``REG_BINARY`` value. Byte 0 controls the enabled state:
|
|
9
|
+
|
|
10
|
+
* ``0x02`` — **enabled** (Windows will honour the ``Run`` entry).
|
|
11
|
+
* ``0x03`` — **disabled** (entry hidden from startup by Task Manager /
|
|
12
|
+
Settings → Startup Apps).
|
|
13
|
+
|
|
14
|
+
When registering or removing auto-startup we synchronise *both* keys
|
|
15
|
+
so that a user toggling the setting in-app overrides any prior
|
|
16
|
+
Task-Manager disable.
|
|
17
|
+
|
|
18
|
+
Other platforms are stubbed with no-op implementations, matching the
|
|
19
|
+
approach in :mod:`synodic_client.protocol`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
STARTUP_VALUE_NAME = 'SynodicClient'
|
|
28
|
+
"""Registry value name used in the ``Run`` key."""
|
|
29
|
+
|
|
30
|
+
RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run'
|
|
31
|
+
|
|
32
|
+
STARTUP_APPROVED_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run'
|
|
33
|
+
"""Registry key where Windows stores per-entry enabled/disabled flags."""
|
|
34
|
+
|
|
35
|
+
# 12-byte REG_BINARY payloads for the StartupApproved value.
|
|
36
|
+
APPROVED_ENABLED: bytes = b'\x02' + b'\x00' * 11
|
|
37
|
+
"""Enabled payload for the ``StartupApproved\\Run`` registry value."""
|
|
38
|
+
|
|
39
|
+
_APPROVED_DISABLED_BYTE: int = 0x03
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if sys.platform == 'win32':
|
|
43
|
+
import winreg
|
|
44
|
+
|
|
45
|
+
def register_startup(exe_path: str) -> None:
|
|
46
|
+
r"""Register the application to start automatically on login.
|
|
47
|
+
|
|
48
|
+
Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
|
|
49
|
+
pointing to *exe_path* **and** writes an *enabled* flag to the
|
|
50
|
+
corresponding ``StartupApproved\Run`` key so that a previous
|
|
51
|
+
Task-Manager disable is overridden.
|
|
52
|
+
|
|
53
|
+
Calling this repeatedly is safe and will update the path (useful
|
|
54
|
+
after Velopack relocates the executable).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
exe_path: Absolute path to the application executable.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
|
61
|
+
winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_SZ, f'"{exe_path}"')
|
|
62
|
+
logger.info('Registered auto-startup -> %s', exe_path)
|
|
63
|
+
except OSError:
|
|
64
|
+
logger.exception('Failed to register auto-startup')
|
|
65
|
+
|
|
66
|
+
# Ensure Windows considers the entry enabled even if the user
|
|
67
|
+
# previously disabled it via Task Manager.
|
|
68
|
+
try:
|
|
69
|
+
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH) as key:
|
|
70
|
+
winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_BINARY, APPROVED_ENABLED)
|
|
71
|
+
logger.debug('Wrote StartupApproved enabled flag')
|
|
72
|
+
except OSError:
|
|
73
|
+
logger.exception('Failed to write StartupApproved enabled flag')
|
|
74
|
+
|
|
75
|
+
def remove_startup() -> None:
|
|
76
|
+
"""Remove the auto-startup registration.
|
|
77
|
+
|
|
78
|
+
Removes both the ``Run`` value and any ``StartupApproved`` flag.
|
|
79
|
+
Silently succeeds if the values do not exist.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
|
83
|
+
winreg.DeleteValue(key, STARTUP_VALUE_NAME)
|
|
84
|
+
logger.info('Removed auto-startup registration')
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
logger.debug('Auto-startup registration not found, nothing to remove')
|
|
87
|
+
except OSError:
|
|
88
|
+
logger.exception('Failed to remove auto-startup registration')
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
|
92
|
+
winreg.DeleteValue(key, STARTUP_VALUE_NAME)
|
|
93
|
+
logger.debug('Removed StartupApproved flag')
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
logger.debug('StartupApproved flag not found, nothing to remove')
|
|
96
|
+
except OSError:
|
|
97
|
+
logger.exception('Failed to remove StartupApproved flag')
|
|
98
|
+
|
|
99
|
+
def is_startup_registered() -> bool:
|
|
100
|
+
r"""Check whether auto-startup is both present **and** enabled.
|
|
101
|
+
|
|
102
|
+
Returns ``True`` only when the ``Run`` value exists and Windows
|
|
103
|
+
has not disabled it via ``StartupApproved\Run``.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
``True`` if the application will auto-start on login.
|
|
107
|
+
"""
|
|
108
|
+
# 1. Check the Run key exists at all.
|
|
109
|
+
try:
|
|
110
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
|
|
111
|
+
winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
|
|
112
|
+
except FileNotFoundError:
|
|
113
|
+
return False
|
|
114
|
+
except OSError:
|
|
115
|
+
logger.exception('Failed to query auto-startup registration')
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
# 2. Check the StartupApproved override. If the key/value is
|
|
119
|
+
# absent the entry is considered enabled (Windows only writes
|
|
120
|
+
# this key when the user explicitly disables/enables via Task
|
|
121
|
+
# Manager or Settings).
|
|
122
|
+
try:
|
|
123
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, STARTUP_APPROVED_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
|
|
124
|
+
data, _ = winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
|
|
125
|
+
if isinstance(data, bytes) and len(data) >= 1 and data[0] == _APPROVED_DISABLED_BYTE:
|
|
126
|
+
logger.debug('Auto-startup is disabled via StartupApproved')
|
|
127
|
+
return False
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
# No approval override → treat as enabled.
|
|
130
|
+
pass
|
|
131
|
+
except OSError:
|
|
132
|
+
logger.exception('Failed to query StartupApproved flag')
|
|
133
|
+
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
else:
|
|
137
|
+
|
|
138
|
+
def register_startup(exe_path: str) -> None:
|
|
139
|
+
"""Register auto-startup (no-op on non-Windows).
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
exe_path: Absolute path to the application executable.
|
|
143
|
+
"""
|
|
144
|
+
logger.warning('Auto-startup registration is only supported on Windows (current: %s)', sys.platform)
|
|
145
|
+
|
|
146
|
+
def remove_startup() -> None:
|
|
147
|
+
"""Remove auto-startup registration (no-op on non-Windows)."""
|
|
148
|
+
logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform)
|
|
149
|
+
|
|
150
|
+
def is_startup_registered() -> bool:
|
|
151
|
+
"""Check auto-startup registration (always ``False`` on non-Windows).
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
``False``.
|
|
155
|
+
"""
|
|
156
|
+
return False
|
|
@@ -113,10 +113,10 @@ class UpdateInfo:
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
# Default interval for automatic update checks (minutes)
|
|
116
|
-
DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES =
|
|
116
|
+
DEFAULT_AUTO_UPDATE_INTERVAL_MINUTES = 5
|
|
117
117
|
|
|
118
118
|
# Default interval for tool update checks (minutes)
|
|
119
|
-
DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES =
|
|
119
|
+
DEFAULT_TOOL_UPDATE_INTERVAL_MINUTES = 5
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
@dataclass
|
|
@@ -433,6 +433,9 @@ def _on_before_uninstall(version: str) -> None:
|
|
|
433
433
|
logger.warning('Auto-startup removal failed during uninstall hook', exc_info=True)
|
|
434
434
|
|
|
435
435
|
|
|
436
|
+
_velopack_initialized = False
|
|
437
|
+
|
|
438
|
+
|
|
436
439
|
def initialize_velopack() -> None:
|
|
437
440
|
"""Initialize Velopack at application startup.
|
|
438
441
|
|
|
@@ -440,13 +443,18 @@ def initialize_velopack() -> None:
|
|
|
440
443
|
before any UI is shown. Velopack may need to perform cleanup or apply
|
|
441
444
|
pending updates.
|
|
442
445
|
|
|
443
|
-
|
|
446
|
+
Safe to call more than once — subsequent calls are no-ops.
|
|
444
447
|
|
|
445
448
|
.. note::
|
|
446
449
|
|
|
447
450
|
The SDK's callback hooks only accept ``PyCFunction`` — add an
|
|
448
451
|
uninstall hook here when that is fixed upstream.
|
|
449
452
|
"""
|
|
453
|
+
global _velopack_initialized # noqa: PLW0603
|
|
454
|
+
if _velopack_initialized:
|
|
455
|
+
return
|
|
456
|
+
_velopack_initialized = True
|
|
457
|
+
|
|
450
458
|
logger.info('Initializing Velopack (exe=%s)', sys.executable)
|
|
451
459
|
try:
|
|
452
460
|
app = velopack.App()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Tests for the shared startup preamble."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import MagicMock, patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import synodic_client.application.init as init_mod
|
|
8
|
+
from synodic_client.application.init import run_startup_preamble
|
|
9
|
+
|
|
10
|
+
_MODULE = 'synodic_client.application.init'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _reset_preamble_guard() -> None:
|
|
15
|
+
"""Reset the idempotency guard before each test."""
|
|
16
|
+
init_mod._preamble_done = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestRunStartupPreamble:
|
|
20
|
+
"""Verify that run_startup_preamble orchestrates the correct calls."""
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def test_calls_seed_and_register_protocol() -> None:
|
|
24
|
+
"""Seed, protocol registration, and config resolution are invoked."""
|
|
25
|
+
with (
|
|
26
|
+
patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
|
|
27
|
+
patch(f'{_MODULE}.register_protocol') as mock_proto,
|
|
28
|
+
patch(f'{_MODULE}.resolve_config') as mock_resolve,
|
|
29
|
+
patch(f'{_MODULE}.register_startup'),
|
|
30
|
+
patch(f'{_MODULE}.remove_startup'),
|
|
31
|
+
):
|
|
32
|
+
mock_resolve.return_value = MagicMock(auto_start=True)
|
|
33
|
+
run_startup_preamble(r'C:\app\synodic.exe')
|
|
34
|
+
|
|
35
|
+
mock_seed.assert_called_once()
|
|
36
|
+
mock_proto.assert_called_once_with(r'C:\app\synodic.exe')
|
|
37
|
+
mock_resolve.assert_called_once()
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def test_registers_startup_when_auto_start_true() -> None:
|
|
41
|
+
"""register_startup is called when auto_start is True."""
|
|
42
|
+
with (
|
|
43
|
+
patch(f'{_MODULE}.seed_user_config_from_build'),
|
|
44
|
+
patch(f'{_MODULE}.register_protocol'),
|
|
45
|
+
patch(f'{_MODULE}.resolve_config') as mock_resolve,
|
|
46
|
+
patch(f'{_MODULE}.register_startup') as mock_register,
|
|
47
|
+
patch(f'{_MODULE}.remove_startup') as mock_remove,
|
|
48
|
+
):
|
|
49
|
+
mock_resolve.return_value = MagicMock(auto_start=True)
|
|
50
|
+
run_startup_preamble(r'C:\app\synodic.exe')
|
|
51
|
+
|
|
52
|
+
mock_register.assert_called_once_with(r'C:\app\synodic.exe')
|
|
53
|
+
mock_remove.assert_not_called()
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def test_removes_startup_when_auto_start_false() -> None:
|
|
57
|
+
"""remove_startup is called when auto_start is False."""
|
|
58
|
+
with (
|
|
59
|
+
patch(f'{_MODULE}.seed_user_config_from_build'),
|
|
60
|
+
patch(f'{_MODULE}.register_protocol'),
|
|
61
|
+
patch(f'{_MODULE}.resolve_config') as mock_resolve,
|
|
62
|
+
patch(f'{_MODULE}.register_startup') as mock_register,
|
|
63
|
+
patch(f'{_MODULE}.remove_startup') as mock_remove,
|
|
64
|
+
):
|
|
65
|
+
mock_resolve.return_value = MagicMock(auto_start=False)
|
|
66
|
+
run_startup_preamble(r'C:\app\synodic.exe')
|
|
67
|
+
|
|
68
|
+
mock_remove.assert_called_once()
|
|
69
|
+
mock_register.assert_not_called()
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def test_defaults_exe_path_to_sys_executable() -> None:
|
|
73
|
+
"""When exe_path is None, sys.executable is used."""
|
|
74
|
+
with (
|
|
75
|
+
patch(f'{_MODULE}.seed_user_config_from_build'),
|
|
76
|
+
patch(f'{_MODULE}.register_protocol') as mock_proto,
|
|
77
|
+
patch(f'{_MODULE}.resolve_config') as mock_resolve,
|
|
78
|
+
patch(f'{_MODULE}.register_startup') as mock_register,
|
|
79
|
+
patch(f'{_MODULE}.remove_startup'),
|
|
80
|
+
patch(f'{_MODULE}.sys') as mock_sys,
|
|
81
|
+
):
|
|
82
|
+
mock_sys.executable = r'C:\Python\python.exe'
|
|
83
|
+
mock_resolve.return_value = MagicMock(auto_start=True)
|
|
84
|
+
run_startup_preamble()
|
|
85
|
+
|
|
86
|
+
mock_proto.assert_called_once_with(r'C:\Python\python.exe')
|
|
87
|
+
mock_register.assert_called_once_with(r'C:\Python\python.exe')
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def test_idempotent_on_second_call() -> None:
|
|
91
|
+
"""A second call is a no-op; the preamble runs only once."""
|
|
92
|
+
with (
|
|
93
|
+
patch(f'{_MODULE}.seed_user_config_from_build') as mock_seed,
|
|
94
|
+
patch(f'{_MODULE}.register_protocol'),
|
|
95
|
+
patch(f'{_MODULE}.resolve_config') as mock_resolve,
|
|
96
|
+
patch(f'{_MODULE}.register_startup'),
|
|
97
|
+
patch(f'{_MODULE}.remove_startup'),
|
|
98
|
+
):
|
|
99
|
+
mock_resolve.return_value = MagicMock(auto_start=True)
|
|
100
|
+
run_startup_preamble(r'C:\app\synodic.exe')
|
|
101
|
+
run_startup_preamble(r'C:\app\synodic.exe')
|
|
102
|
+
|
|
103
|
+
mock_seed.assert_called_once()
|
|
@@ -6,6 +6,7 @@ import pytest
|
|
|
6
6
|
import velopack
|
|
7
7
|
from packaging.version import Version
|
|
8
8
|
|
|
9
|
+
import synodic_client.updater as updater_mod
|
|
9
10
|
from synodic_client.updater import (
|
|
10
11
|
GITHUB_REPO_URL,
|
|
11
12
|
UpdateChannel,
|
|
@@ -416,6 +417,12 @@ class TestUpdaterApplyUpdate:
|
|
|
416
417
|
class TestInitializeVelopack:
|
|
417
418
|
"""Tests for initialize_velopack function."""
|
|
418
419
|
|
|
420
|
+
@staticmethod
|
|
421
|
+
@pytest.fixture(autouse=True)
|
|
422
|
+
def _reset_velopack_guard() -> None:
|
|
423
|
+
"""Reset the idempotency guard before each test."""
|
|
424
|
+
updater_mod._velopack_initialized = False
|
|
425
|
+
|
|
419
426
|
@staticmethod
|
|
420
427
|
def test_initialize_success() -> None:
|
|
421
428
|
"""Verify initialize_velopack calls App().run()."""
|
|
@@ -434,6 +441,15 @@ class TestInitializeVelopack:
|
|
|
434
441
|
# Should not raise
|
|
435
442
|
initialize_velopack()
|
|
436
443
|
|
|
444
|
+
@staticmethod
|
|
445
|
+
def test_idempotent_on_second_call() -> None:
|
|
446
|
+
"""A second call is a no-op; Velopack is initialised only once."""
|
|
447
|
+
mock_app = MagicMock(spec=velopack.App)
|
|
448
|
+
with patch('synodic_client.updater.velopack.App', return_value=mock_app) as mock_app_class:
|
|
449
|
+
initialize_velopack()
|
|
450
|
+
initialize_velopack()
|
|
451
|
+
mock_app_class.assert_called_once()
|
|
452
|
+
|
|
437
453
|
|
|
438
454
|
class TestGetVelopackManager:
|
|
439
455
|
"""Tests for _get_velopack_manager install detection via the SDK."""
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Tests for Windows auto-startup registration."""
|
|
2
|
+
|
|
3
|
+
import winreg
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from synodic_client.startup import (
|
|
7
|
+
APPROVED_ENABLED,
|
|
8
|
+
RUN_KEY_PATH,
|
|
9
|
+
STARTUP_APPROVED_KEY_PATH,
|
|
10
|
+
STARTUP_VALUE_NAME,
|
|
11
|
+
is_startup_registered,
|
|
12
|
+
register_startup,
|
|
13
|
+
remove_startup,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestRegisterStartup:
|
|
18
|
+
"""Tests for register_startup."""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def test_writes_registry_value() -> None:
|
|
22
|
+
"""Verify correct registry value is written on Windows."""
|
|
23
|
+
mock_key = MagicMock()
|
|
24
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
25
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
26
|
+
|
|
27
|
+
with (
|
|
28
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open,
|
|
29
|
+
patch.object(winreg, 'SetValueEx') as mock_set,
|
|
30
|
+
patch.object(winreg, 'CreateKey', return_value=mock_key),
|
|
31
|
+
):
|
|
32
|
+
register_startup(r'C:\Program Files\Synodic\synodic.exe')
|
|
33
|
+
|
|
34
|
+
mock_open.assert_called_once_with(
|
|
35
|
+
winreg.HKEY_CURRENT_USER,
|
|
36
|
+
RUN_KEY_PATH,
|
|
37
|
+
0,
|
|
38
|
+
winreg.KEY_SET_VALUE,
|
|
39
|
+
)
|
|
40
|
+
mock_set.assert_any_call(
|
|
41
|
+
mock_key,
|
|
42
|
+
STARTUP_VALUE_NAME,
|
|
43
|
+
0,
|
|
44
|
+
winreg.REG_SZ,
|
|
45
|
+
r'"C:\Program Files\Synodic\synodic.exe"',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def test_writes_startup_approved_enabled() -> None:
|
|
50
|
+
"""Verify the StartupApproved enabled flag is written."""
|
|
51
|
+
mock_run_key = MagicMock()
|
|
52
|
+
mock_run_key.__enter__ = MagicMock(return_value=mock_run_key)
|
|
53
|
+
mock_run_key.__exit__ = MagicMock(return_value=False)
|
|
54
|
+
|
|
55
|
+
mock_approved_key = MagicMock()
|
|
56
|
+
mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key)
|
|
57
|
+
mock_approved_key.__exit__ = MagicMock(return_value=False)
|
|
58
|
+
|
|
59
|
+
with (
|
|
60
|
+
patch.object(winreg, 'OpenKey', return_value=mock_run_key),
|
|
61
|
+
patch.object(winreg, 'SetValueEx') as mock_set,
|
|
62
|
+
patch.object(winreg, 'CreateKey', return_value=mock_approved_key) as mock_create,
|
|
63
|
+
):
|
|
64
|
+
register_startup(r'C:\synodic.exe')
|
|
65
|
+
|
|
66
|
+
mock_create.assert_called_once_with(
|
|
67
|
+
winreg.HKEY_CURRENT_USER,
|
|
68
|
+
STARTUP_APPROVED_KEY_PATH,
|
|
69
|
+
)
|
|
70
|
+
mock_set.assert_any_call(
|
|
71
|
+
mock_approved_key,
|
|
72
|
+
STARTUP_VALUE_NAME,
|
|
73
|
+
0,
|
|
74
|
+
winreg.REG_BINARY,
|
|
75
|
+
APPROVED_ENABLED,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def test_noop_on_non_windows() -> None:
|
|
80
|
+
"""Verify register_startup is a no-op on non-Windows platforms."""
|
|
81
|
+
with patch('synodic_client.startup.sys') as mock_sys:
|
|
82
|
+
mock_sys.platform = 'linux'
|
|
83
|
+
register_startup('/usr/bin/synodic')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TestRemoveStartup:
|
|
87
|
+
"""Tests for remove_startup."""
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def test_deletes_registry_value() -> None:
|
|
91
|
+
"""Verify the startup value is deleted."""
|
|
92
|
+
mock_key = MagicMock()
|
|
93
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
94
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
95
|
+
|
|
96
|
+
with (
|
|
97
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
98
|
+
patch.object(winreg, 'DeleteValue') as mock_delete,
|
|
99
|
+
):
|
|
100
|
+
remove_startup()
|
|
101
|
+
|
|
102
|
+
mock_delete.assert_any_call(mock_key, STARTUP_VALUE_NAME)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def test_clears_startup_approved() -> None:
|
|
106
|
+
"""Verify the StartupApproved flag is also deleted."""
|
|
107
|
+
mock_run_key = MagicMock()
|
|
108
|
+
mock_run_key.__enter__ = MagicMock(return_value=mock_run_key)
|
|
109
|
+
mock_run_key.__exit__ = MagicMock(return_value=False)
|
|
110
|
+
|
|
111
|
+
mock_approved_key = MagicMock()
|
|
112
|
+
mock_approved_key.__enter__ = MagicMock(return_value=mock_approved_key)
|
|
113
|
+
mock_approved_key.__exit__ = MagicMock(return_value=False)
|
|
114
|
+
|
|
115
|
+
def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock:
|
|
116
|
+
if 'Explorer' in path:
|
|
117
|
+
return mock_approved_key
|
|
118
|
+
return mock_run_key
|
|
119
|
+
|
|
120
|
+
with (
|
|
121
|
+
patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect),
|
|
122
|
+
patch.object(winreg, 'DeleteValue') as mock_delete,
|
|
123
|
+
):
|
|
124
|
+
remove_startup()
|
|
125
|
+
|
|
126
|
+
# Both the Run and StartupApproved values should be deleted
|
|
127
|
+
expected_delete_count = 2
|
|
128
|
+
assert mock_delete.call_count == expected_delete_count
|
|
129
|
+
mock_delete.assert_any_call(mock_run_key, STARTUP_VALUE_NAME)
|
|
130
|
+
mock_delete.assert_any_call(mock_approved_key, STARTUP_VALUE_NAME)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def test_handles_missing_value_gracefully() -> None:
|
|
134
|
+
"""Verify no error when startup value doesn't exist."""
|
|
135
|
+
mock_key = MagicMock()
|
|
136
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
137
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
138
|
+
|
|
139
|
+
with (
|
|
140
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
141
|
+
patch.object(winreg, 'DeleteValue', side_effect=FileNotFoundError),
|
|
142
|
+
):
|
|
143
|
+
# Should not raise
|
|
144
|
+
remove_startup()
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def test_noop_on_non_windows() -> None:
|
|
148
|
+
"""Verify remove_startup is a no-op on non-Windows platforms."""
|
|
149
|
+
with patch('synodic_client.startup.sys') as mock_sys:
|
|
150
|
+
mock_sys.platform = 'linux'
|
|
151
|
+
remove_startup()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TestIsStartupRegistered:
|
|
155
|
+
"""Tests for is_startup_registered."""
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def test_returns_true_when_present() -> None:
|
|
159
|
+
"""Verify True when the value exists and no approval override."""
|
|
160
|
+
mock_key = MagicMock()
|
|
161
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
162
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
163
|
+
|
|
164
|
+
def _open_key_side_effect(_root: int, path: str, _reserved: int, _access: int) -> MagicMock:
|
|
165
|
+
return mock_key
|
|
166
|
+
|
|
167
|
+
def _query_side_effect(key: MagicMock, name: str) -> tuple[object, int]:
|
|
168
|
+
# Run key exists; StartupApproved key raises FileNotFoundError
|
|
169
|
+
# (no override → treated as enabled)
|
|
170
|
+
raise FileNotFoundError
|
|
171
|
+
|
|
172
|
+
with (
|
|
173
|
+
patch.object(winreg, 'OpenKey', side_effect=_open_key_side_effect),
|
|
174
|
+
patch.object(
|
|
175
|
+
winreg,
|
|
176
|
+
'QueryValueEx',
|
|
177
|
+
side_effect=[
|
|
178
|
+
(r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
|
|
179
|
+
FileNotFoundError, # StartupApproved query
|
|
180
|
+
],
|
|
181
|
+
),
|
|
182
|
+
):
|
|
183
|
+
assert is_startup_registered() is True
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def test_returns_true_when_startup_approved_enabled() -> None:
|
|
187
|
+
"""Verify True when the StartupApproved byte is 0x02 (enabled)."""
|
|
188
|
+
mock_key = MagicMock()
|
|
189
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
190
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
191
|
+
|
|
192
|
+
enabled_data = b'\x02' + b'\x00' * 11
|
|
193
|
+
|
|
194
|
+
with (
|
|
195
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
196
|
+
patch.object(
|
|
197
|
+
winreg,
|
|
198
|
+
'QueryValueEx',
|
|
199
|
+
side_effect=[
|
|
200
|
+
(r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
|
|
201
|
+
(enabled_data, winreg.REG_BINARY), # StartupApproved query
|
|
202
|
+
],
|
|
203
|
+
),
|
|
204
|
+
):
|
|
205
|
+
assert is_startup_registered() is True
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def test_returns_false_when_startup_approved_disabled() -> None:
|
|
209
|
+
"""Verify False when the StartupApproved byte is 0x03 (disabled)."""
|
|
210
|
+
mock_key = MagicMock()
|
|
211
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
212
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
213
|
+
|
|
214
|
+
disabled_data = b'\x03' + b'\x00' * 11
|
|
215
|
+
|
|
216
|
+
with (
|
|
217
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
218
|
+
patch.object(
|
|
219
|
+
winreg,
|
|
220
|
+
'QueryValueEx',
|
|
221
|
+
side_effect=[
|
|
222
|
+
(r'"C:\synodic.exe"', winreg.REG_SZ), # Run key query
|
|
223
|
+
(disabled_data, winreg.REG_BINARY), # StartupApproved query
|
|
224
|
+
],
|
|
225
|
+
),
|
|
226
|
+
):
|
|
227
|
+
assert is_startup_registered() is False
|
|
228
|
+
|
|
229
|
+
@staticmethod
|
|
230
|
+
def test_returns_false_when_missing() -> None:
|
|
231
|
+
"""Verify False when the value does not exist."""
|
|
232
|
+
mock_key = MagicMock()
|
|
233
|
+
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
234
|
+
mock_key.__exit__ = MagicMock(return_value=False)
|
|
235
|
+
|
|
236
|
+
with (
|
|
237
|
+
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
238
|
+
patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
|
|
239
|
+
):
|
|
240
|
+
assert is_startup_registered() is False
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = '0.0.1.dev42'
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
r"""Windows auto-startup registration via the registry.
|
|
2
|
-
|
|
3
|
-
Manages a value under ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
|
|
4
|
-
so the application launches automatically when the user logs in.
|
|
5
|
-
|
|
6
|
-
Other platforms are stubbed with no-op implementations, matching the
|
|
7
|
-
approach in :mod:`synodic_client.protocol`.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
import sys
|
|
12
|
-
|
|
13
|
-
logger = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
|
-
STARTUP_VALUE_NAME = 'SynodicClient'
|
|
16
|
-
"""Registry value name used in the ``Run`` key."""
|
|
17
|
-
|
|
18
|
-
RUN_KEY_PATH = r'Software\Microsoft\Windows\CurrentVersion\Run'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if sys.platform == 'win32':
|
|
22
|
-
import winreg
|
|
23
|
-
|
|
24
|
-
def register_startup(exe_path: str) -> None:
|
|
25
|
-
r"""Register the application to start automatically on login.
|
|
26
|
-
|
|
27
|
-
Writes a value to ``HKCU\Software\Microsoft\Windows\CurrentVersion\Run``
|
|
28
|
-
pointing to *exe_path*. Calling this repeatedly is safe and will
|
|
29
|
-
update the path (useful after Velopack relocates the executable).
|
|
30
|
-
|
|
31
|
-
Args:
|
|
32
|
-
exe_path: Absolute path to the application executable.
|
|
33
|
-
"""
|
|
34
|
-
try:
|
|
35
|
-
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
|
36
|
-
winreg.SetValueEx(key, STARTUP_VALUE_NAME, 0, winreg.REG_SZ, f'"{exe_path}"')
|
|
37
|
-
logger.info('Registered auto-startup -> %s', exe_path)
|
|
38
|
-
except OSError:
|
|
39
|
-
logger.exception('Failed to register auto-startup')
|
|
40
|
-
|
|
41
|
-
def remove_startup() -> None:
|
|
42
|
-
"""Remove the auto-startup registration.
|
|
43
|
-
|
|
44
|
-
Silently succeeds if the value does not exist.
|
|
45
|
-
"""
|
|
46
|
-
try:
|
|
47
|
-
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key:
|
|
48
|
-
winreg.DeleteValue(key, STARTUP_VALUE_NAME)
|
|
49
|
-
logger.info('Removed auto-startup registration')
|
|
50
|
-
except FileNotFoundError:
|
|
51
|
-
logger.debug('Auto-startup registration not found, nothing to remove')
|
|
52
|
-
except OSError:
|
|
53
|
-
logger.exception('Failed to remove auto-startup registration')
|
|
54
|
-
|
|
55
|
-
def is_startup_registered() -> bool:
|
|
56
|
-
"""Check whether the auto-startup value is currently present.
|
|
57
|
-
|
|
58
|
-
Returns:
|
|
59
|
-
``True`` if the ``Run`` key contains the startup value.
|
|
60
|
-
"""
|
|
61
|
-
try:
|
|
62
|
-
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_QUERY_VALUE) as key:
|
|
63
|
-
winreg.QueryValueEx(key, STARTUP_VALUE_NAME)
|
|
64
|
-
return True
|
|
65
|
-
except FileNotFoundError:
|
|
66
|
-
return False
|
|
67
|
-
except OSError:
|
|
68
|
-
logger.exception('Failed to query auto-startup registration')
|
|
69
|
-
return False
|
|
70
|
-
|
|
71
|
-
else:
|
|
72
|
-
|
|
73
|
-
def register_startup(exe_path: str) -> None:
|
|
74
|
-
"""Register auto-startup (no-op on non-Windows).
|
|
75
|
-
|
|
76
|
-
Args:
|
|
77
|
-
exe_path: Absolute path to the application executable.
|
|
78
|
-
"""
|
|
79
|
-
logger.warning('Auto-startup registration is only supported on Windows (current: %s)', sys.platform)
|
|
80
|
-
|
|
81
|
-
def remove_startup() -> None:
|
|
82
|
-
"""Remove auto-startup registration (no-op on non-Windows)."""
|
|
83
|
-
logger.warning('Auto-startup removal is only supported on Windows (current: %s)', sys.platform)
|
|
84
|
-
|
|
85
|
-
def is_startup_registered() -> bool:
|
|
86
|
-
"""Check auto-startup registration (always ``False`` on non-Windows).
|
|
87
|
-
|
|
88
|
-
Returns:
|
|
89
|
-
``False``.
|
|
90
|
-
"""
|
|
91
|
-
return False
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"""Tests for Windows auto-startup registration."""
|
|
2
|
-
|
|
3
|
-
import winreg
|
|
4
|
-
from unittest.mock import MagicMock, patch
|
|
5
|
-
|
|
6
|
-
from synodic_client.startup import (
|
|
7
|
-
RUN_KEY_PATH,
|
|
8
|
-
STARTUP_VALUE_NAME,
|
|
9
|
-
is_startup_registered,
|
|
10
|
-
register_startup,
|
|
11
|
-
remove_startup,
|
|
12
|
-
)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class TestRegisterStartup:
|
|
16
|
-
"""Tests for register_startup."""
|
|
17
|
-
|
|
18
|
-
@staticmethod
|
|
19
|
-
def test_writes_registry_value() -> None:
|
|
20
|
-
"""Verify correct registry value is written on Windows."""
|
|
21
|
-
mock_key = MagicMock()
|
|
22
|
-
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
23
|
-
mock_key.__exit__ = MagicMock(return_value=False)
|
|
24
|
-
|
|
25
|
-
with (
|
|
26
|
-
patch.object(winreg, 'OpenKey', return_value=mock_key) as mock_open,
|
|
27
|
-
patch.object(winreg, 'SetValueEx') as mock_set,
|
|
28
|
-
):
|
|
29
|
-
register_startup(r'C:\Program Files\Synodic\synodic.exe')
|
|
30
|
-
|
|
31
|
-
mock_open.assert_called_once_with(
|
|
32
|
-
winreg.HKEY_CURRENT_USER,
|
|
33
|
-
RUN_KEY_PATH,
|
|
34
|
-
0,
|
|
35
|
-
winreg.KEY_SET_VALUE,
|
|
36
|
-
)
|
|
37
|
-
mock_set.assert_called_once_with(
|
|
38
|
-
mock_key,
|
|
39
|
-
STARTUP_VALUE_NAME,
|
|
40
|
-
0,
|
|
41
|
-
winreg.REG_SZ,
|
|
42
|
-
r'"C:\Program Files\Synodic\synodic.exe"',
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
@staticmethod
|
|
46
|
-
def test_noop_on_non_windows() -> None:
|
|
47
|
-
"""Verify register_startup is a no-op on non-Windows platforms."""
|
|
48
|
-
with patch('synodic_client.startup.sys') as mock_sys:
|
|
49
|
-
mock_sys.platform = 'linux'
|
|
50
|
-
register_startup('/usr/bin/synodic')
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class TestRemoveStartup:
|
|
54
|
-
"""Tests for remove_startup."""
|
|
55
|
-
|
|
56
|
-
@staticmethod
|
|
57
|
-
def test_deletes_registry_value() -> None:
|
|
58
|
-
"""Verify the startup value is deleted."""
|
|
59
|
-
mock_key = MagicMock()
|
|
60
|
-
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
61
|
-
mock_key.__exit__ = MagicMock(return_value=False)
|
|
62
|
-
|
|
63
|
-
with (
|
|
64
|
-
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
65
|
-
patch.object(winreg, 'DeleteValue') as mock_delete,
|
|
66
|
-
):
|
|
67
|
-
remove_startup()
|
|
68
|
-
|
|
69
|
-
mock_delete.assert_called_once_with(mock_key, STARTUP_VALUE_NAME)
|
|
70
|
-
|
|
71
|
-
@staticmethod
|
|
72
|
-
def test_handles_missing_value_gracefully() -> None:
|
|
73
|
-
"""Verify no error when startup value doesn't exist."""
|
|
74
|
-
mock_key = MagicMock()
|
|
75
|
-
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
76
|
-
mock_key.__exit__ = MagicMock(return_value=False)
|
|
77
|
-
|
|
78
|
-
with (
|
|
79
|
-
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
80
|
-
patch.object(winreg, 'DeleteValue', side_effect=FileNotFoundError),
|
|
81
|
-
):
|
|
82
|
-
# Should not raise
|
|
83
|
-
remove_startup()
|
|
84
|
-
|
|
85
|
-
@staticmethod
|
|
86
|
-
def test_noop_on_non_windows() -> None:
|
|
87
|
-
"""Verify remove_startup is a no-op on non-Windows platforms."""
|
|
88
|
-
with patch('synodic_client.startup.sys') as mock_sys:
|
|
89
|
-
mock_sys.platform = 'linux'
|
|
90
|
-
remove_startup()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
class TestIsStartupRegistered:
|
|
94
|
-
"""Tests for is_startup_registered."""
|
|
95
|
-
|
|
96
|
-
@staticmethod
|
|
97
|
-
def test_returns_true_when_present() -> None:
|
|
98
|
-
"""Verify True when the value exists."""
|
|
99
|
-
mock_key = MagicMock()
|
|
100
|
-
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
101
|
-
mock_key.__exit__ = MagicMock(return_value=False)
|
|
102
|
-
|
|
103
|
-
with (
|
|
104
|
-
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
105
|
-
patch.object(winreg, 'QueryValueEx', return_value=(r'"C:\synodic.exe"', winreg.REG_SZ)),
|
|
106
|
-
):
|
|
107
|
-
assert is_startup_registered() is True
|
|
108
|
-
|
|
109
|
-
@staticmethod
|
|
110
|
-
def test_returns_false_when_missing() -> None:
|
|
111
|
-
"""Verify False when the value does not exist."""
|
|
112
|
-
mock_key = MagicMock()
|
|
113
|
-
mock_key.__enter__ = MagicMock(return_value=mock_key)
|
|
114
|
-
mock_key.__exit__ = MagicMock(return_value=False)
|
|
115
|
-
|
|
116
|
-
with (
|
|
117
|
-
patch.object(winreg, 'OpenKey', return_value=mock_key),
|
|
118
|
-
patch.object(winreg, 'QueryValueEx', side_effect=FileNotFoundError),
|
|
119
|
-
):
|
|
120
|
-
assert is_startup_registered() is False
|
|
121
|
-
assert is_startup_registered() is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/__init__.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/data.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/icon.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/instance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/card.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/screen/tray.py
RENAMED
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/theme.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/synodic_client/application/workers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_gather_packages.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_install_preview.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_preview_model.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_tray_window_show.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_banner.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_controller.py
RENAMED
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/qt/test_update_feedback.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{synodic_client-0.0.1.dev42 → synodic_client-0.0.1.dev44}/tests/unit/windows/test_protocol.py
RENAMED
|
File without changes
|