Release SyncVito 1.0.13

This commit is contained in:
Davito committed 2026-09-28 11:08:14 +02:00
1 parent b6e32a1b18
commit cdcd03d7b0
8 files changed
+284 -20

No files matched your search

+9
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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"
+20 -2
View File
@@ -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:
+140 -15
View File
@@ -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 (
'<?xml version="1.0" encoding="UTF-16"?>\n'
'<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">\n'
" <RegistrationInfo>\n"
" <Description>SyncVito Windows Autostart</Description>\n"
" </RegistrationInfo>\n"
" <Triggers>\n"
" <LogonTrigger>\n"
" <Enabled>true</Enabled>\n"
" <Delay>PT5S</Delay>\n"
" </LogonTrigger>\n"
" </Triggers>\n"
" <Principals>\n"
' <Principal id="Author">\n'
" <LogonType>InteractiveToken</LogonType>\n"
" <RunLevel>HighestAvailable</RunLevel>\n"
" </Principal>\n"
" </Principals>\n"
" <Settings>\n"
" <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n"
" <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n"
" <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n"
" <AllowHardTerminate>true</AllowHardTerminate>\n"
" <StartWhenAvailable>true</StartWhenAvailable>\n"
" <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>\n"
" <IdleSettings>\n"
" <StopOnIdleEnd>false</StopOnIdleEnd>\n"
" <RestartOnIdle>false</RestartOnIdle>\n"
" </IdleSettings>\n"
" <AllowStartOnDemand>true</AllowStartOnDemand>\n"
" <Enabled>true</Enabled>\n"
" <Hidden>false</Hidden>\n"
" <RunOnlyIfIdle>false</RunOnlyIfIdle>\n"
" <WakeToRun>false</WakeToRun>\n"
" <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n"
" <Priority>7</Priority>\n"
" </Settings>\n"
' <Actions Context="Author">\n'
" <Exec>\n"
f" <Command>{escaped_exe}</Command>\n"
f" <Arguments>{escaped_args}</Arguments>\n"
" </Exec>\n"
" </Actions>\n"
"</Task>"
)
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
+101
View File
@@ -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 '<?xml version="1.0" encoding="UTF-16"?>' in xml
assert "<RunLevel>HighestAvailable</RunLevel>" in xml
assert "<LogonType>InteractiveToken</LogonType>" in xml
assert "<Delay>PT5S</Delay>" in xml
assert "<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>" in xml
assert "<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>" in xml
assert "<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>" in xml
assert "<Command>C:\\Program Files\\SyncVito\\SyncVito.exe</Command>" in xml
assert "<Arguments>--minimized</Arguments>" 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
+9 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.0.12
1.0.13