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