From cdcd03d7b04303764679256cac6bfa4c5ec72792 Mon Sep 17 00:00:00 2001 From: Davito <1+davito@noreply.localhost> Date: Mon, 28 Sep 2026 11:08:14 +0200 Subject: [PATCH] Release SyncVito 1.0.13 --- CHANGELOG.md | 9 +++ installer/SyncVito.iss | 3 + pyproject.toml | 2 +- src/syncvito/app.py | 22 +++++- src/syncvito/autostart.py | 155 ++++++++++++++++++++++++++++++++++---- tests/test_autostart.py | 101 +++++++++++++++++++++++++ tests/test_installer.py | 10 ++- version.txt | 2 +- 8 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 tests/test_autostart.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c4c24..7d96076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Änderungsprotokoll +## 1.0.13 – 2026-09-28 + +### Behoben + +- **Zuverlässiger Windows-Autostart via Aufgabenplanung:** Da SyncVito mit Administratorrechten (`requireAdministrator`) läuft, blockierte der Windows Explorer beim Systemstart den Aufruf aus dem Registry-`Run`-Schlüssel. Der Autostart wird nun über eine geplante Windows-Aufgabe (`RunLevel=HighestAvailable`) bei Benutzeranmeldung ausgeführt, sodass SyncVito nach dem Hochfahren zuverlässig und ohne UAC-Dialog im Infobereich startet. +- **Warteschleife für den Windows-Infobereich:** Falls der Windows Explorer beim Booten den Benachrichtigungsbereich noch nicht initialisiert hat, wartet SyncVito bis zu 15 Sekunden auf den Tray, statt sich sofort mit einem Fehler zu beenden. +- **Automatische Autostart-Migration:** Bestehende Konfigurationen mit aktiviertem Autostart werden beim Programmstart automatisch auf die geplante Aufgabe umgestellt; alte Einträge im Run-Schlüssel werden bereinigt. +- **Saubere Deinstallation:** Bei einer Deinstallation über Inno Setup wird die registrierte Windows-Aufgabe für den Autostart automatisch entfernt. + ## 1.0.12 – 2026-09-27 ### Behoben diff --git a/installer/SyncVito.iss b/installer/SyncVito.iss index 56a176d..5d55220 100644 --- a/installer/SyncVito.iss +++ b/installer/SyncVito.iss @@ -55,3 +55,6 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{#MyAppName} starten"; Flags: nowait postinstall skipifsilent runascurrentuser Filename: "{app}\{#MyAppExeName}"; Parameters: "--updated"; Flags: nowait skipifnotsilent runascurrentuser + +[UninstallRun] +Filename: "schtasks.exe"; Parameters: "/delete /tn ""{#MyAppName}"" /f"; Flags: runhidden diff --git a/pyproject.toml b/pyproject.toml index dad07a1..b4ed660 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "syncvito" -version = "1.0.12" +version = "1.0.13" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" readme = "README.md" requires-python = ">=3.11" diff --git a/src/syncvito/app.py b/src/syncvito/app.py index e2f00b9..9df9dc1 100644 --- a/src/syncvito/app.py +++ b/src/syncvito/app.py @@ -2,12 +2,15 @@ from __future__ import annotations +import os import sys +import time from pathlib import Path from PyQt6.QtCore import QLockFile, QStandardPaths from PyQt6.QtWidgets import QApplication, QMessageBox, QSystemTrayIcon +from syncvito.autostart import set_autostart from syncvito.config_store import ConfigError, ConfigStore from syncvito.icons import app_icon from syncvito.main_window import MainWindow @@ -18,6 +21,19 @@ from syncvito.version import __version__ __all__ = ["main"] +def _wait_for_system_tray(application: QApplication, timeout_seconds: float = 15.0) -> bool: + """Wartet beim Windows-Start auf die Verfügbarkeit des Infobereichs.""" + if QSystemTrayIcon.isSystemTrayAvailable(): + return True + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + application.processEvents() + time.sleep(0.25) + if QSystemTrayIcon.isSystemTrayAvailable(): + return True + return False + + def main() -> int: """Startet SyncVito als einzelne Windows-Instanz.""" if "--version" in sys.argv: @@ -33,11 +49,11 @@ def main() -> int: lock_path = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.TempLocation)) / "syncvito.lock" lock = QLockFile(str(lock_path)) - lock.setStaleLockTime(0) + lock.setStaleLockTime(30000) if not lock.tryLock(100): QMessageBox.information(None, "SyncVito läuft bereits", "SyncVito ist bereits im Infobereich aktiv.") return 0 - if not QSystemTrayIcon.isSystemTrayAvailable(): + if not _wait_for_system_tray(application): QMessageBox.critical(None, "Infobereich nicht verfügbar", "SyncVito benötigt den Windows-Infobereich.") return 1 @@ -47,6 +63,8 @@ def main() -> int: except ConfigError as exc: QMessageBox.warning(None, "Konfiguration zurückgesetzt", str(exc)) config = AppConfig() + if os.name == "nt" and config.settings.autostart: + set_autostart(True) apply_theme(application, config.settings) window = MainWindow(config, store) if "--minimized" not in sys.argv: diff --git a/src/syncvito/autostart.py b/src/syncvito/autostart.py index 104713c..0c254dd 100644 --- a/src/syncvito/autostart.py +++ b/src/syncvito/autostart.py @@ -3,29 +3,154 @@ from __future__ import annotations import os +import subprocess import sys +import tempfile from contextlib import suppress from pathlib import Path +from xml.sax.saxutils import escape as xml_escape -__all__ = ["set_autostart"] +__all__ = ["RUN_KEY_PATH", "TASK_NAME", "generate_task_xml", "set_autostart"] + +TASK_NAME = "SyncVito" +RUN_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run" -def set_autostart(enabled: bool) -> None: - """Setzt oder entfernt SyncVito unter HKCU\\...\\Run.""" +def generate_task_xml(command_exe: Path, arguments: str = "--minimized") -> str: + """Erzeugt die XML-Definition für die Windows-Aufgabenplanung.""" + escaped_exe = xml_escape(str(command_exe.resolve())) + escaped_args = xml_escape(arguments) + return ( + '\n' + '\n' + " \n" + " SyncVito Windows Autostart\n" + " \n" + " \n" + " \n" + " true\n" + " PT5S\n" + " \n" + " \n" + " \n" + ' \n' + " InteractiveToken\n" + " HighestAvailable\n" + " \n" + " \n" + " \n" + " IgnoreNew\n" + " false\n" + " false\n" + " true\n" + " true\n" + " false\n" + " \n" + " false\n" + " false\n" + " \n" + " true\n" + " true\n" + " false\n" + " false\n" + " false\n" + " PT0S\n" + " 7\n" + " \n" + ' \n' + " \n" + f" {escaped_exe}\n" + f" {escaped_args}\n" + " \n" + " \n" + "" + ) + + +def _run_schtasks(args: list[str]) -> subprocess.CompletedProcess[str]: + flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + return subprocess.run( + ["schtasks", *args], + capture_output=True, + text=True, + creationflags=flags, + check=False, + ) + + +def _delete_run_key_entry() -> None: if os.name != "nt": return import winreg - key_path = r"Software\Microsoft\Windows\CurrentVersion\Run" - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: - if enabled: - executable = Path(sys.executable) - if getattr(sys, "frozen", False): - command = f'"{executable}" --minimized' - else: - entry = Path(__file__).resolve().parents[2] / "main.py" - command = f'"{executable}" "{entry}" --minimized' - winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command) + with ( + suppress(FileNotFoundError, OSError), + winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key, + ): + winreg.DeleteValue(key, TASK_NAME) + + +def _set_run_key_entry(command: str) -> None: + if os.name != "nt": + return + import winreg + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY_PATH, 0, winreg.KEY_SET_VALUE) as key: + winreg.SetValueEx(key, TASK_NAME, 0, winreg.REG_SZ, command) + + +def _register_scheduled_task(command_exe: Path, arguments: str) -> bool: + xml_content = generate_task_xml(command_exe, arguments) + fd, tmp_file = tempfile.mkstemp(suffix=".xml") + tmp_path = Path(tmp_file) + try: + with os.fdopen(fd, "w", encoding="utf-16") as f: + f.write(xml_content) + result = _run_schtasks(["/create", "/tn", TASK_NAME, "/xml", str(tmp_path), "/f"]) + return result.returncode == 0 + except OSError: + return False + finally: + with suppress(OSError): + tmp_path.unlink() + + +def _delete_scheduled_task() -> bool: + result = _run_schtasks(["/delete", "/tn", TASK_NAME, "/f"]) + return result.returncode == 0 + + +def set_autostart(enabled: bool) -> bool: + """Aktiviert oder entfernt SyncVito im Windows-Autostart. + + Da SyncVito Administratorrechte (requireAdministrator) benötigt, + ignoriert der Windows-Explorer Einträge unter HKCU\\...\\Run beim Systemstart + vollständig, um unerwünschte UAC-Abfragen beim Logon zu verhindern. + Für erhöhte Anwendungen muss daher ein Scheduled Task in der Windows- + Aufgabenplanung mit 'RunLevel=HighestAvailable' verwendet werden. + """ + if os.name != "nt": + return False + + if enabled: + if getattr(sys, "frozen", False): + command_exe = Path(sys.executable) + arguments = "--minimized" + reg_command = f'"{command_exe}" --minimized' else: - with suppress(FileNotFoundError): - winreg.DeleteValue(key, "SyncVito") + command_exe = Path(sys.executable) + entry = Path(__file__).resolve().parents[2] / "main.py" + arguments = f'"{entry}" --minimized' + reg_command = f'"{command_exe}" "{entry}" --minimized' + + if _register_scheduled_task(command_exe, arguments): + _delete_run_key_entry() + return True + + # Fallback auf Run-Schlüssel (z. B. nicht-erhöhte Entwicklungsumgebung) + _set_run_key_entry(reg_command) + return False + else: + _delete_scheduled_task() + _delete_run_key_entry() + return True diff --git a/tests/test_autostart.py b/tests/test_autostart.py new file mode 100644 index 0000000..377a2de --- /dev/null +++ b/tests/test_autostart.py @@ -0,0 +1,101 @@ +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +import syncvito.autostart as autostart +from syncvito.autostart import generate_task_xml, set_autostart + + +def test_generate_task_xml_structure() -> None: + cmd = Path("C:/Program Files/SyncVito/SyncVito.exe") + args = "--minimized" + xml = generate_task_xml(cmd, args) + + assert '' in xml + assert "HighestAvailable" in xml + assert "InteractiveToken" in xml + assert "PT5S" in xml + assert "false" in xml + assert "false" in xml + assert "PT0S" in xml + assert "C:\\Program Files\\SyncVito\\SyncVito.exe" in xml + assert "--minimized" in xml + + +def test_set_autostart_non_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autostart.os, "name", "posix") + assert not set_autostart(True) + assert not set_autostart(False) + + +def test_set_autostart_enable_scheduled_task_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autostart.os, "name", "nt") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe") + + mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0)) + monkeypatch.setattr(autostart, "_run_schtasks", mock_run) + + deleted_run_key = False + + def mock_delete_run_key() -> None: + nonlocal deleted_run_key + deleted_run_key = True + + monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key) + + result = set_autostart(True) + assert result is True + assert mock_run.called + create_call_args = mock_run.call_args[0][0] + assert create_call_args[0] == "/create" + assert create_call_args[1] == "/tn" + assert create_call_args[2] == "SyncVito" + assert deleted_run_key is True + + +def test_set_autostart_enable_fallback_registry(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autostart.os, "name", "nt") + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "executable", r"C:\Program Files\SyncVito\SyncVito.exe") + + # schtasks fails (e.g. non-elevated prompt) + mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=1)) + monkeypatch.setattr(autostart, "_run_schtasks", mock_run) + + registry_command = None + + def mock_set_run_key(cmd: str) -> None: + nonlocal registry_command + registry_command = cmd + + monkeypatch.setattr(autostart, "_set_run_key_entry", mock_set_run_key) + + result = set_autostart(True) + assert result is False + assert registry_command == r'"C:\Program Files\SyncVito\SyncVito.exe" --minimized' + + +def test_set_autostart_disable(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(autostart.os, "name", "nt") + + mock_run = MagicMock(return_value=subprocess.CompletedProcess(args=[], returncode=0)) + monkeypatch.setattr(autostart, "_run_schtasks", mock_run) + + deleted_run_key = False + + def mock_delete_run_key() -> None: + nonlocal deleted_run_key + deleted_run_key = True + + monkeypatch.setattr(autostart, "_delete_run_key_entry", mock_delete_run_key) + + result = set_autostart(False) + assert result is True + assert mock_run.called + delete_call_args = mock_run.call_args[0][0] + assert delete_call_args == ["/delete", "/tn", "SyncVito", "/f"] + assert deleted_run_key is True diff --git a/tests/test_installer.py b/tests/test_installer.py index 0ad1097..c6d34fe 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -3,9 +3,17 @@ from pathlib import Path def test_installer_launches_admin_application_with_elevated_setup_token() -> None: script = Path("installer/SyncVito.iss").read_text(encoding="utf-8") - run_entries = [line for line in script.splitlines() if line.startswith("Filename:")] + run_section = script.split("[Run]")[1].split("[")[0] if "[Run]" in script else "" + run_entries = [line for line in run_section.splitlines() if line.startswith("Filename:")] assert len(run_entries) == 2 assert all("runascurrentuser" in entry for entry in run_entries) assert "postinstall" in run_entries[0] assert 'Parameters: "--updated"' in run_entries[1] + + +def test_installer_cleans_up_scheduled_task_on_uninstall() -> None: + script = Path("installer/SyncVito.iss").read_text(encoding="utf-8") + uninstall_section = script.split("[UninstallRun]")[1].split("[")[0] if "[UninstallRun]" in script else "" + assert "schtasks.exe" in uninstall_section + assert "{#MyAppName}" in uninstall_section diff --git a/version.txt b/version.txt index bb83058..2ac9634 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.12 +1.0.13