Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdcd03d7b0 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
+136
-11
@@ -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:
|
||||
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:
|
||||
executable = Path(sys.executable)
|
||||
if getattr(sys, "frozen", False):
|
||||
command = f'"{executable}" --minimized'
|
||||
command_exe = Path(sys.executable)
|
||||
arguments = "--minimized"
|
||||
reg_command = f'"{command_exe}" --minimized'
|
||||
else:
|
||||
command_exe = Path(sys.executable)
|
||||
entry = Path(__file__).resolve().parents[2] / "main.py"
|
||||
command = f'"{executable}" "{entry}" --minimized'
|
||||
winreg.SetValueEx(key, "SyncVito", 0, winreg.REG_SZ, command)
|
||||
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:
|
||||
with suppress(FileNotFoundError):
|
||||
winreg.DeleteValue(key, "SyncVito")
|
||||
_delete_scheduled_task()
|
||||
_delete_run_key_entry()
|
||||
return True
|
||||
@@ -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
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.0.12
|
||||
1.0.13
|
||||
Reference in new issue
Block a user